├── test ├── mocha.opts ├── static │ ├── index.html │ └── bevy.json ├── gitapp │ ├── .gitignore │ ├── bevy.json │ ├── app.js │ └── package.json ├── repo-session.js └── basic-run.js ├── examples ├── static-site │ ├── index.html │ ├── bevy.json │ └── README.md └── hello-app │ ├── app.js │ ├── bevy.json │ ├── package.json │ └── README.md ├── .travis.yml ├── .gitignore ├── package.json ├── lib ├── cli-utils.js ├── static-server.js ├── repo.js └── server.js ├── bin ├── bevy-server.js └── bevy.js └── README.md /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --reporter spec 2 | -------------------------------------------------------------------------------- /test/static/index.html: -------------------------------------------------------------------------------- 1 |

ohai!

-------------------------------------------------------------------------------- /test/gitapp/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /test/gitapp/bevy.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": { 3 | "type": "git" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /examples/static-site/index.html: -------------------------------------------------------------------------------- 1 |

ohai!

2 | 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | 2 | language: node_js 3 | node_js: 4 | - "0.11" 5 | - "0.10" 6 | - "0.8" 7 | env: 8 | - BEVY_DOMAIN=127.0.0.1 9 | -------------------------------------------------------------------------------- /test/static/bevy.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "static" 3 | , "static": true 4 | , "repository": { 5 | "type": "local" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/gitapp/app.js: -------------------------------------------------------------------------------- 1 | var express = require("express") 2 | , app = express() 3 | ; 4 | 5 | app.get("/", function(req, res){ 6 | res.send("

dynamic ohai!

"); 7 | }); 8 | app.listen(process.env.PORT); 9 | -------------------------------------------------------------------------------- /examples/hello-app/app.js: -------------------------------------------------------------------------------- 1 | var express = require("express") 2 | , app = express() 3 | ; 4 | 5 | app.get("/", function(req, res){ 6 | res.send("

dynamic ohai!

"); 7 | }); 8 | app.listen(process.env.PORT); 9 | -------------------------------------------------------------------------------- /examples/hello-app/bevy.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "127.0.0.1", 3 | "deploy": "http://localhost:8042/", 4 | "repository": { 5 | "type": "local", 6 | "path": "/@@@path/to@@@/bevy/examples/hello-app/" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | NOTES.txt 2 | node_modules 3 | dev-config.json 4 | store 5 | scratch 6 | test/store 7 | TODO.txt 8 | .npmignore 9 | node-http-proxy 10 | install-and-restart.sh 11 | send 12 | forever-monitor 13 | ssl 14 | bevy-test-repo 15 | -------------------------------------------------------------------------------- /test/gitapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitapp" 3 | , "version": "42.20.17" 4 | , "private": true 5 | , "dependencies": { 6 | "express": "*" 7 | } 8 | , "scripts": { 9 | "start": "app.js" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/hello-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hello-app" 3 | , "version": "42.20.17" 4 | , "private": true 5 | , "dependencies": { 6 | "express": "*" 7 | } 8 | , "scripts": { 9 | "start": "app.js" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/static-site/bevy.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "static-site", 3 | "domain": "127.0.0.1", 4 | "deploy": "http://localhost:8042/", 5 | "static": true, 6 | "repository": { 7 | "type": "local", 8 | "path": "/@@@path/to@@@/bevy/examples/static-site/" 9 | } 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bevy" 3 | , "description": "A simple server to manage multiple Node services" 4 | , "version": "0.4.5" 5 | , "license": "MIT" 6 | , "bin": { 7 | "bevy-server": "./bin/bevy-server.js" 8 | , "bevy": "./bin/bevy.js" 9 | } 10 | , "dependencies": { 11 | "proxima": "0.4.6" 12 | , "forever-monitor": "git://github.com/darobin/forever-monitor#logger-maxlisteners" 13 | , "utile": "0.2.0" 14 | , "portfinder": "0.2.1" 15 | , "express": "3.2.4" 16 | , "touch": "0.0.2" 17 | , "deep-equal": "0.0.0" 18 | , "send": "git://github.com/darobin/send#multiple-index" 19 | , "nopt": "2.1.1" 20 | , "request": "2.21.0" 21 | , "shortid": "1.0.8" 22 | } 23 | , "devDependencies": { 24 | "mocha": "1.10.0" 25 | , "expect.js": "0.2.0" 26 | , "prompt": "0.2.9" 27 | } 28 | , "scripts": { 29 | "test": "mocha" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/cli-utils.js: -------------------------------------------------------------------------------- 1 | 2 | var pth = require("path") 3 | , fs = require("fs") 4 | ; 5 | 6 | // die on error 7 | exports.die = function (msg) { 8 | console.log("[ERROR]", msg); 9 | process.exit(1); 10 | }; 11 | 12 | // show usage 13 | exports.usage = function (usage, marker) { 14 | console.log("Usage: " + usage + "\n"); 15 | var README = fs.readFileSync(pth.join(__dirname, "../README.md"), "utf8"); 16 | README = README.replace(new RegExp("[\\S\\s]*"), "") 17 | .replace(new RegExp("[\\S\\s]*"), "") 18 | .replace(/```/g, ""); 19 | var options = README.split(/^\* /m); 20 | options.shift(); 21 | var rex = /^([^:]+):\s*([\S\s]+)/; 22 | options.forEach(function (opt) { 23 | var matches = rex.exec(opt) 24 | , prms = matches[1].split(", ") 25 | , out = [] 26 | ; 27 | prms.forEach(function (prm) { 28 | if (prm.indexOf("-") > -1) out.push(prm); 29 | }); 30 | console.log("\t* " + out.join(", ") + ": " + matches[2]); 31 | }); 32 | process.exit(0); 33 | }; 34 | -------------------------------------------------------------------------------- /examples/static-site/README.md: -------------------------------------------------------------------------------- 1 | 2 | # Running a static site with Bevy 3 | 4 | First, start a toy bevy-server instance from inside a clone of this repository on a free port (if 5 | you pick a different port, change the deploy field in bevy.json): 6 | 7 | node ../../bin/bevy-server.js -d localhost -p 8042 -s /var/tmp 8 | 9 | You can check that it's running at http://localhost:8042/. 10 | 11 | Second, change bevy.json here so that path points to this directory. 12 | 13 | Note that in order to make this example truly easy to run, we consider that your domain name is 14 | "127.0.0.1", which is unrealistic but saves you from having to configure anything locally. If you 15 | have a hostname (other than localhost, which is used for the bevy endpoint above) that points to 16 | your local machine you can use that instead. 17 | 18 | Run: 19 | 20 | node ../../bin/bevy.js deploy 21 | 22 | You can see that it's configured at http://localhost:8042/apps, but not running. 23 | 24 | Run: 25 | 26 | node ../../bin/bevy.js start 27 | 28 | If you refresh the above, you will see that it is now running. 29 | 30 | Access http://127.0.0.1:8042/. You should see the content of index.html. 31 | 32 | In a real-world scenario: 33 | * The bevy server would already be running, so that you can skip step 1. 34 | * The commands would be installed, so that you can run "bevy" instead of node ../../bin/bevy.js. 35 | * The ports would be 80 (or 443) instead of 8042. 36 | -------------------------------------------------------------------------------- /lib/static-server.js: -------------------------------------------------------------------------------- 1 | 2 | var send = require("send") 3 | , http = require("http") 4 | , url = require("url") 5 | , fs = require("fs") 6 | , port = process.env.PORT 7 | , root = process.env.BEVY_ROOT 8 | , conf = process.env.BEVY_CONFIG ? JSON.parse(fs.readFileSync(process.env.BEVY_CONFIG, "utf8")) : {} 9 | , version = require("../package.json").version 10 | ; 11 | 12 | if (!port || !root) { 13 | console.log("Missing port and root (environment PORT and BEVY_ROOT)."); 14 | process.exit(1); 15 | } 16 | 17 | var app = http.createServer(function (req, res) { 18 | // identify self 19 | res.setHeader("Server", "Bevy/" + version); 20 | 21 | // simple error handler 22 | function error (err) { 23 | res.statusCode = err.status || 500; 24 | res.end(err.message); 25 | } 26 | 27 | // properly redirect to directories 28 | function redirectDir () { 29 | res.statusCode = 301; 30 | res.setHeader("Location", req.url + "/"); 31 | res.end("Redirecting to " + req.url + "/"); 32 | } 33 | 34 | // run! 35 | send(req, url.parse(req.url).pathname) 36 | .on("error", error) 37 | .root(root) 38 | .on("directory", redirectDir) 39 | .index(conf.directoryIndex || "index.html") 40 | .pipe(res); 41 | }); 42 | app.on("error", function (err) { 43 | console.log("[ERROR]", err); 44 | }); 45 | app.listen(port); 46 | console.log("Static server listening on port", port, "at root", root); 47 | -------------------------------------------------------------------------------- /bin/bevy-server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var nopt = require("nopt") 4 | , pth = require("path") 5 | , fs = require("fs") 6 | , bevy = require("../lib/server") 7 | , cliUtils = require("../lib/cli-utils") 8 | , utile = require("utile") 9 | , knownOpts = { 10 | config: String 11 | , domain: String 12 | , ports: [Array, Number] 13 | , store: String 14 | , security: String 15 | , uid: String 16 | , gid: String 17 | , help: Boolean 18 | } 19 | , shortHands = { 20 | f: ["--config"] 21 | , d: ["--domain"] 22 | , p: ["--ports"] 23 | , s: ["--store"] 24 | , u: ["--uid"] 25 | , g: ["--gid"] 26 | , h: ["--help"] 27 | } 28 | , cli = nopt(knownOpts, shortHands, process.argv, 2) 29 | , configPath = cli.config ? pth.resolve(process.cwd(), cli.config) : "/etc/bevy/config.json" 30 | , config = {} 31 | ; 32 | delete cli.argv; 33 | 34 | // go to help immediately if requested 35 | if (cli.help) cliUtils.usage("bevy-server [OPTIONS]", "bevy-server"); 36 | 37 | // load the config and override it with CLI parameters 38 | if (fs.existsSync(configPath)) config = JSON.parse(fs.readFileSync(configPath, "utf8")); 39 | else if (cli.config) cliUtils.die("Configuration file not found: " + cli.config + " (resolved to " + configPath + ")"); 40 | config = utile.mixin(config, cli); 41 | 42 | // run bevy, run! 43 | bevy.run(config); 44 | -------------------------------------------------------------------------------- /examples/hello-app/README.md: -------------------------------------------------------------------------------- 1 | 2 | # Running a Node site with Bevy 3 | 4 | First, start a toy bevy-server instance from inside a clone of this repository on a free port (if 5 | you pick a different port, change the deploy field in bevy.json): 6 | 7 | node ../../bin/bevy-server.js -d localhost -p 8042 -s /var/tmp 8 | 9 | You can check that it's running at http://localhost:8042/. 10 | 11 | Second, change bevy.json here so that path points to this directory. 12 | 13 | Note that in order to make this example truly easy to run, we consider that your domain name is 14 | "127.0.0.1", which is unrealistic but saves you from having to configure anything locally. If you 15 | have a hostname (other than localhost, which is used for the bevy endpoint above) that points to 16 | your local machine you can use that instead. 17 | 18 | Now make sure that you have all the dependencies for the app: 19 | 20 | npm install -d 21 | 22 | Again, in this case Bevy does not install the dependencies for you because the source for the app 23 | is a local directory rather than a git repository. In a more realistic scenario, such as deploying 24 | to a remote server, Bevy would fetch the content of your app from git and install the dependencies 25 | you need. 26 | 27 | Run: 28 | 29 | node ../../bin/bevy.js deploy 30 | 31 | You can see that it's configured at http://localhost:8042/apps, but may not be running. 32 | 33 | Run: 34 | 35 | node ../../bin/bevy.js start 36 | 37 | If you refresh the above, you will see that it is now running. 38 | 39 | Access http://127.0.0.1:8042/. You should see the content of index.html. 40 | 41 | In a real-world scenario: 42 | * The bevy server would already be running, so that you can skip step 1. 43 | * The commands would be installed, so that you can run "bevy" instead of node ../../bin/bevy.js. 44 | * The ports would be 80 (or 443) instead of 8042. 45 | * Bevy would fetch your 46 | 47 | The difference between this example and a real-world case is just a few configuration options. 48 | 49 | -------------------------------------------------------------------------------- /test/repo-session.js: -------------------------------------------------------------------------------- 1 | /*jshint es5: true*/ 2 | /*global before, after, describe*/ 3 | 4 | var expect = require("expect.js") 5 | , pth = require("path") 6 | , fs = require("fs") 7 | , repo = require("../lib/repo") 8 | , utile = require("utile") 9 | , repoPath = pth.join(__dirname, "repo") 10 | // , spawn = require("child_process").spawn 11 | // , exec = require("child_process").exec 12 | // , portfinder = require("portfinder") 13 | // , request = require("request") 14 | // , serverPath = pth.join(__dirname, "../bin/bevy-server.js") 15 | // , bevyPath = pth.join(__dirname, "../bin/bevy.js") 16 | // , version = require("../package.json").version 17 | // , debug = false 18 | // , WAIT = 750 19 | // , server 20 | // , deployPort 21 | // , testDomain 22 | // , api = "http://localhost:" 23 | ; 24 | 25 | function cleanup (done) { 26 | utile.rimraf(repoPath, function (err) { 27 | if (err) throw err; 28 | done(); 29 | }); 30 | } 31 | 32 | before(function (done) { 33 | cleanup(function (err) { 34 | if (err) throw err; 35 | fs.mkdir(repoPath, function (err) { 36 | if (err) throw err; 37 | done(); 38 | }); 39 | }); 40 | }); 41 | after(cleanup); 42 | 43 | describe("Repository basics", function () { 44 | it("Errors for non-git content", function (done) { 45 | repo.update({ repository: { type: "goop" }}, {}, function (err) { 46 | expect(err).to.equal("Unknown repository type: goop"); 47 | done(); 48 | }); 49 | }); 50 | it("Clones a repository with the right session behaviour", function (done) { 51 | this.timeout(10000); 52 | var app = { 53 | repository: { 54 | type: "git" 55 | , url: "https://github.com/darobin/bevy-test-repo.git" 56 | , branch: "repo-test" 57 | } 58 | , contentPath: pth.join(repoPath, "content") 59 | , storePath: repoPath 60 | }; 61 | var seenProgress = false 62 | , seenEnd = false 63 | ; 64 | var session = repo.update(app, {}, function (err) { 65 | expect(err).to.not.be.ok(); 66 | expect(seenProgress).to.be.ok(); 67 | expect(seenEnd).to.be.ok(); 68 | expect(session.messages()).to.be.ok(); 69 | expect(session.messages().length).to.equal(0); 70 | expect(fs.existsSync(pth.join(repoPath, "content/good.js"))).to.be.ok(); 71 | expect(fs.existsSync(pth.join(repoPath, "content/node_modules/web-schema"))).to.be.ok(); 72 | done(); 73 | }); 74 | session.on("progress", function () { 75 | seenProgress = true; 76 | }); 77 | session.on("end", function () { 78 | seenEnd = true; 79 | }); 80 | expect(session.id).to.match(/^[-_\w]+$/); 81 | expect(session.done).to.not.be.ok(); 82 | expect(session.queue.length).to.equal(0); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /lib/repo.js: -------------------------------------------------------------------------------- 1 | 2 | // a fair amount of this stuff is stolen from haibu 3 | var fs = require("fs") 4 | , spawn = require("child_process").spawn 5 | , utils = require("util") 6 | , EventEmitter = require("events").EventEmitter 7 | , shortid = require("shortid") 8 | ; 9 | 10 | // Handle update sessions for long-running processes 11 | function UpdateSession () { 12 | this.id = shortid.generate(); 13 | this.done = false; 14 | this.queue = []; 15 | } 16 | utils.inherits(UpdateSession, EventEmitter); 17 | UpdateSession.prototype.progress = function (msg) { 18 | this.queue.push(["progress", msg]); 19 | this.emit("progress", msg); 20 | return this; 21 | }; 22 | UpdateSession.prototype.error = function (msg) { 23 | this.queue.push(["error", msg]); 24 | this.emit("error", msg); 25 | return this; 26 | }; 27 | UpdateSession.prototype.end = function () { 28 | this.done = true; 29 | this.queue.push(["end"]); 30 | this.emit("end"); 31 | return this; 32 | }; 33 | // returns the queue of messages, and empties it 34 | UpdateSession.prototype.messages = function () { 35 | var ret = this.queue; 36 | this.queue = []; 37 | return ret; 38 | }; 39 | 40 | 41 | function sessionAndCBErr (session, err, cb) { 42 | session.error(err).end(); 43 | cb(err); 44 | } 45 | 46 | function npmInstall (app, conf, session, cb) { 47 | session.progress("Running npm install.\n"); 48 | var spawnOpt = { cwd: app.contentPath }; 49 | if (conf.uid) spawnOpt.uid = conf.uid; 50 | if (conf.gid) spawnOpt.gid = conf.gid; 51 | var child = spawn("npm", ["install", "-d"], spawnOpt); 52 | child.on("error", function (err) { 53 | sessionAndCBErr(session, err, cb); 54 | }); 55 | child.on("exit", function (code) { 56 | if (code === null) return; // normally "error" has been triggered 57 | if (code === 0) { 58 | session.progress("Dependencies installed.\n").end(); 59 | cb(); 60 | } 61 | else { 62 | sessionAndCBErr(session, "Exit code for npm install: " + code, cb); 63 | } 64 | }); 65 | child.stdout.on("data", function (data) { 66 | session.progress(data instanceof Buffer ? data.toString("utf8") : data); 67 | }); 68 | child.stderr.on("data", function (data) { 69 | session.progress(data instanceof Buffer ? data.toString("utf8") : data); 70 | }); 71 | } 72 | 73 | function git (app, conf, session, cb) { 74 | fs.exists(app.contentPath, function (exists) { 75 | var commands = [] 76 | , branch = app.repository.branch || "master" 77 | ; 78 | if (exists) { 79 | commands.push(["git", "fetch origin".split(" "), app.contentPath]); 80 | commands.push(["git", ("reset --hard refs/remotes/origin/" + branch).split(" "), app.contentPath]); 81 | } 82 | else { 83 | // note that you don't want to let just about anyone give you URLs 84 | // they're run on the CLI, you could have a bad time 85 | commands.push(["git", ["clone", "-b", branch, app.repository.url, "content"], app.storePath]); 86 | } 87 | commands.push(["git", "submodule update --init --recursive".split(" "), app.contentPath]); 88 | 89 | var totCmd = commands.length; 90 | function runUntilEmpty () { 91 | var command = commands.shift() 92 | , curCmd = totCmd - commands.length; 93 | session.progress("Running git command " + curCmd + "/" + totCmd + ".\n"); 94 | var spawnOpt = { cwd: command[2] }; 95 | if (conf.uid) spawnOpt.uid = conf.uid; 96 | if (conf.gid) spawnOpt.gid = conf.gid; 97 | var child = spawn(command[0], command[1], spawnOpt); 98 | child.on("error", function (err) { 99 | sessionAndCBErr(session, err, cb); 100 | }); 101 | child.on("exit", function (code) { 102 | if (code === null) return; // normally "error" has been triggered 103 | if (code === 0) { 104 | session.progress("Done: git command " + curCmd + "/" + totCmd + ".\n"); 105 | commands.length > 0 ? runUntilEmpty() : npmInstall(app, conf, session, cb); 106 | } 107 | else { 108 | sessionAndCBErr(session, "Exit code for git: " + code, cb); 109 | } 110 | }); 111 | child.stdout.on("data", function (data) { 112 | session.progress(data instanceof Buffer ? data.toString("utf8") : data); 113 | }); 114 | child.stderr.on("data", function (data) { 115 | if (data instanceof Buffer) data = data.toString("utf8"); 116 | session.progress("[ERR]" + data); 117 | }); 118 | } 119 | runUntilEmpty(); 120 | }); 121 | } 122 | 123 | exports.update = function (app, conf, cb) { 124 | if (app.repository.type === "git") { 125 | var session = new UpdateSession(); 126 | git(app, conf, session, cb); 127 | return session; 128 | } 129 | cb("Unknown repository type: " + app.repository.type); 130 | }; 131 | -------------------------------------------------------------------------------- /bin/bevy.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /*jshint es5: true*/ 3 | 4 | var nopt = require("nopt") 5 | , pth = require("path") 6 | , fs = require("fs") 7 | , cliUtils = require("../lib/cli-utils") 8 | , request = require("request") 9 | , utile = require("utile") 10 | , knownOpts = { 11 | package: String 12 | , bevy: String 13 | , env: ["development", "production"] 14 | , deploy: String 15 | , name: String 16 | , domain: String 17 | , static: Boolean 18 | , type: ["git", "local"] 19 | , url: String 20 | , branch: String 21 | , path: String 22 | , start: String 23 | , to: String 24 | , secure: Boolean 25 | } 26 | , command = process.argv.splice(2, 1)[0] 27 | , cli = nopt(knownOpts, {}, process.argv, 2) 28 | , packPath = pth.resolve(process.cwd(), cli.package || "package.json") 29 | , bevyPath = pth.resolve(process.cwd(), cli.bevy || "bevy.json") 30 | , staging = false 31 | ; 32 | delete cli.argv; 33 | cli.repository = {}; 34 | "type url branch path".split(" ").forEach(function (it) { 35 | if (cli[it]) { 36 | cli.repository[it] = cli[it]; 37 | delete cli[it]; 38 | } 39 | }); 40 | if (!cli.env) cli.env = "development"; 41 | if (command === "stage") { 42 | command = "deploy"; 43 | staging = true; 44 | } 45 | 46 | 47 | // go to help immediately if requested 48 | if (command === "help") cliUtils.usage("bevy deploy|start|stop|remove|stage|help [OPTIONS]", "bevy"); 49 | 50 | // load the config and override it with CLI parameters 51 | var packConf = {}, bevyConf = {}; 52 | if (fs.existsSync(packPath)) packConf = JSON.parse(fs.readFileSync(packPath, "utf8")); 53 | else if (cli.package) cliUtils.die("Could not find package.json: " + cli.package + " (resolved to " + packPath + ")"); 54 | if (fs.existsSync(bevyPath)) bevyConf = JSON.parse(fs.readFileSync(bevyPath, "utf8")); 55 | else if (cli.bevy) cliUtils.die("Could not find bevy.json: " + cli.bevy + " (resolved to " + bevyPath + ")"); 56 | 57 | // merge in the order: package.json < bevy.json < bevy.json[env] < cli 58 | // we need to merge repository separately as it's deep 59 | var conf = utile.mixin({}, packConf, bevyConf, bevyConf[cli.env] || {}, cli); 60 | conf.repository = utile.mixin( packConf.repository || {} 61 | , bevyConf.repository || {} 62 | , bevyConf[cli.env] ? bevyConf[cli.env].repository || {} : {} 63 | , cli.repository 64 | ); 65 | if (staging) conf.env = "production"; 66 | 67 | // validate required 68 | conf.deploy || cliUtils.die("Missing 'deploy' information."); 69 | conf.name || cliUtils.die("Missing 'name' information."); 70 | 71 | // defaulting 72 | if (conf.deploy.indexOf("://") === -1) conf.deploy = "http://" + conf.deploy; 73 | if (!/\/$/.test(conf.deploy)) conf.deploy += "/"; 74 | 75 | // process proxy options 76 | if (cli.to) { 77 | var opt = {}; 78 | if (/^\d+$/.test(cli.to)) opt.port = 1 * cli.to; 79 | else if (/^\//.test(cli.to)) opt.path = cli.to; 80 | else { 81 | var spl = cli.to.split(":"); 82 | opt.host = spl[0]; 83 | opt.port = 1 * spl[1]; 84 | } 85 | opt.secure = cli.secure; 86 | conf.to = opt; 87 | } 88 | 89 | var reqConf = {}; 90 | function simpleRes (err, res, body) { 91 | if (err) return console.log(err); 92 | body = (typeof body === "string") ? JSON.parse(body) : body; 93 | if (body && body.error) return console.log(body.error); 94 | console.log("OK"); 95 | } 96 | 97 | if (command === "deploy") { 98 | if (!conf.to) { 99 | if (conf.repository.type === "local") { 100 | conf.repository.path || cliUtils.die("Missing 'path' information for local repository."); 101 | } 102 | else { // git is the default 103 | conf.repository.url || cliUtils.die("Missing 'url' information for git repository."); 104 | } 105 | } 106 | // get to see if the app exists and put it 107 | request.get(conf.deploy + "app/" + conf.name, reqConf, function (err, res) { 108 | if (err) return console.log(err); 109 | 110 | var notExists = res.statusCode === 404; 111 | reqConf.json = conf; 112 | request.put(conf.deploy + "app/" + conf.name, reqConf, function (err, res, body) { 113 | if (err) return console.log(err); 114 | if (body && body.error) return console.log(body.error); 115 | 116 | delete reqConf.method; // I'm starting to hate this library 117 | delete reqConf.json; 118 | 119 | var whenDone = function () { 120 | if (notExists) { 121 | request.get(conf.deploy + "app/" + conf.name + "/start", reqConf, simpleRes); 122 | } 123 | else { 124 | console.log("OK"); 125 | } 126 | }; 127 | 128 | // a session for a long-running job was opened, poll 129 | if (res.statusCode === 202) { 130 | var url = conf.deploy + "session/" + body.id 131 | , poll = function () { 132 | request.get(url, reqConf, function (err, res, body) { 133 | if (err) return console.log(err); 134 | body = (typeof body === "string") ? JSON.parse(body) : body; 135 | if (body && body.error) return console.log(body.error); 136 | if (body.done) return whenDone(); 137 | for (var i = 0, n = body.messages.length; i < n; i++) { 138 | var msg = body.messages[i]; 139 | if (msg[0] === "error") console.log("[ERROR]"); 140 | if (msg[0] === "end") console.log("Session terminating."); 141 | else process.stdout.write(msg[1]); 142 | } 143 | setTimeout(poll, 3000); 144 | }); 145 | } 146 | ; 147 | poll(); 148 | } 149 | // this succeeded immediately 150 | else { 151 | whenDone(); 152 | } 153 | }); 154 | }); 155 | } 156 | else if (command === "start") { 157 | request.get(conf.deploy + "app/" + conf.name + "/start", reqConf, simpleRes); 158 | } 159 | else if (command === "stop") { 160 | request.get(conf.deploy + "app/" + conf.name + "/stop", reqConf, simpleRes); 161 | } 162 | else if (command === "remove") { 163 | request.del(conf.deploy + "app/" + conf.name, reqConf, simpleRes); 164 | } 165 | else { 166 | cliUtils.die("Unknown command: " + command); 167 | } 168 | 169 | -------------------------------------------------------------------------------- /test/basic-run.js: -------------------------------------------------------------------------------- 1 | /*jshint es5: true*/ 2 | /*global before, after, describe*/ 3 | 4 | var expect = require("expect.js") 5 | , pth = require("path") 6 | , fs = require("fs") 7 | , spawn = require("child_process").spawn 8 | , exec = require("child_process").exec 9 | , portfinder = require("portfinder") 10 | , request = require("request") 11 | , utile = require("utile") 12 | , serverPath = pth.join(__dirname, "../bin/bevy-server.js") 13 | , bevyPath = pth.join(__dirname, "../bin/bevy.js") 14 | , storePath = pth.join(__dirname, "store") 15 | , version = require("../package.json").version 16 | , debug = false 17 | , WAIT = 750 18 | , server 19 | , deployPort 20 | , testDomain 21 | , api = "http://localhost:" 22 | ; 23 | 24 | before(function (done) { 25 | this.timeout(0); 26 | utile.rimraf(storePath, function (err) { 27 | if (err) throw err; 28 | portfinder.getPort(function (err, port) { 29 | if (err) throw err; 30 | api += port + "/"; 31 | deployPort = port; 32 | var serverOpt = ["-d", "localhost" 33 | , "-p", port 34 | , "-s", storePath 35 | ]; 36 | if (process.getuid) { 37 | serverOpt.push("-u"); 38 | serverOpt.push(process.getuid()); 39 | } 40 | if (process.getgid) { 41 | serverOpt.push("-g"); 42 | serverOpt.push(process.getgid()); 43 | } 44 | if (debug) console.log("SERVER OPTIONS:", serverOpt.join(" ")); 45 | server = spawn(serverPath, serverOpt); 46 | if (debug) { 47 | server.stdout.on("data", function (data) { console.log("[SERVER OUT]", data.toString()); }); 48 | server.stderr.on("data", function (data) { console.log("[SERVER OUT]", data.toString()); }); 49 | server.on("exit", function (code, sig) { console.log("[SERVER EXIT]", code, sig); }); 50 | server.on("error", function (err) { console.log("[SERVER ERROR]", err); }); 51 | } 52 | var seen = false; 53 | server.stdout.on("data", function () { 54 | if (seen) return; 55 | seen = true; 56 | testDomain = process.env.BEVY_DOMAIN || "127.0.0.1"; 57 | setTimeout(done, WAIT); 58 | }); 59 | }); 60 | }); 61 | }); 62 | 63 | after(function (done) { 64 | if (server) server.kill("SIGTERM"); 65 | done(); 66 | }); 67 | 68 | 69 | describe("Server basics", function () { 70 | it("creates a store", function () { 71 | expect(fs.existsSync(storePath)).to.be.ok(); 72 | }); 73 | 74 | it("starts a basic server", function (done) { 75 | request.get(api + "version", function (err, res, body) { 76 | body = JSON.parse(body); 77 | expect(body.bevy).to.equal(version); 78 | done(); 79 | }); 80 | }); 81 | 82 | it("has no apps", function (done) { 83 | request.get(api + "apps", function (err, res, body) { 84 | expect(err).to.be(null); 85 | body = JSON.parse(body); 86 | var count = 0; 87 | for (var k in body) if (body.hasOwnProperty(k)) count++; 88 | expect(count).to.equal(0); 89 | done(); 90 | }); 91 | }); 92 | }); 93 | 94 | function checkInApps (field, running, done) { 95 | request.get(api + "apps", function (err, res, body) { 96 | expect(err).to.be(null); 97 | body = JSON.parse(body); 98 | expect(body[field]).to.be.ok(); 99 | expect(body[field].running).to.equal(running); 100 | done(); 101 | }); 102 | } 103 | 104 | function actionThenCheck (url, field, running, done) { 105 | request.get(url, function (err) { 106 | expect(err).to.be(null); 107 | checkInApps(field, running, done); 108 | }); 109 | } 110 | 111 | describe("Static server", function () { 112 | var oldDir = process.cwd(); 113 | before(function (done) { 114 | var staticDir = pth.join(__dirname, "static"); 115 | process.chdir(staticDir); 116 | exec(bevyPath + " deploy --deploy " + api + " --path " + staticDir + " --domain " + testDomain, function (err, stdout, stderr) { 117 | if (stdout && debug) console.log("[STDOUT]", stdout); 118 | if (stderr && debug) console.log("[STDERR]", stderr); 119 | setTimeout(done, WAIT); // wait a bit because it can take a little while to spawn 120 | }); 121 | }); 122 | after(function (done) { 123 | exec(bevyPath + " remove --deploy " + api, function (err, stdout, stderr) { 124 | if (stdout && debug) console.log("[STDOUT]", stdout); 125 | if (stderr && debug) console.log("[STDERR]", stderr); 126 | process.chdir(oldDir); 127 | done(); 128 | }); 129 | }); 130 | it("serves basic content", function (done) { 131 | request.get("http://" + testDomain + ":" + deployPort, function (err, res, body) { 132 | expect(err).to.be(null); 133 | expect(body).to.equal("

ohai!

"); 134 | done(); 135 | }); 136 | }); 137 | it("shows up in list of apps", function (done) { checkInApps("static", true, done); }); 138 | it("stops", function (done) { actionThenCheck(api + "app/static/stop", "static", false, done); }); 139 | it("restarts", function (done) { actionThenCheck(api + "app/static/start", "static", true, done); }); 140 | }); 141 | 142 | describe("Dynamic server", function () { 143 | this.timeout(60000); 144 | var oldDir = process.cwd(); 145 | before(function (done) { 146 | var appDir = pth.join(__dirname, "gitapp"); 147 | process.chdir(appDir); 148 | console.log(" This can take a little while..."); 149 | exec(bevyPath + " deploy --deploy " + api + " --url " + appDir + " --domain " + testDomain, function (err, stdout, stderr) { 150 | if (stdout && debug) console.log("[STDOUT]", stdout); 151 | if (stderr && debug) console.log("[STDERR]", stderr); 152 | setTimeout(done, WAIT); // wait a bit because it can take a little while to spawn 153 | }); 154 | }); 155 | after(function (done) { 156 | var runRemove = function () { 157 | exec(bevyPath + " remove --deploy " + api, function (err, stdout, stderr) { 158 | if (stdout && debug) console.log("[STDOUT]", stdout); 159 | if (stderr && debug) console.log("[STDERR]", stderr); 160 | process.chdir(oldDir); 161 | done(); 162 | }); 163 | }; 164 | if (debug) { 165 | request.get(api + "apps", { json: true }, function (err, res, body) { 166 | console.log(JSON.stringify(body, null, 4)); 167 | runRemove(); 168 | }); 169 | } 170 | else { 171 | runRemove(); 172 | } 173 | }); 174 | it("serves basic content", function (done) { 175 | request.get("http://" + testDomain + ":" + deployPort, function (err, res, body) { 176 | expect(err).to.be(null); 177 | expect(body).to.equal("

dynamic ohai!

"); 178 | done(); 179 | }); 180 | }); 181 | it("shows up in list of apps", function (done) { checkInApps("gitapp", true, done); }); 182 | it("stops", function (done) { actionThenCheck(api + "app/gitapp/stop", "gitapp", false, done); }); 183 | it("restarts", function (done) { actionThenCheck(api + "app/gitapp/start", "gitapp", true, done); }); 184 | }); 185 | 186 | describe("Proxy to arbitrary service", function () { 187 | before(function (done) { 188 | exec(bevyPath + " deploy --deploy " + api + " --name proxy --to 173.194.34.131:80 --domain " + testDomain, function (err, stdout, stderr) { 189 | if (stdout && debug) console.log("[STDOUT]", stdout); 190 | if (stderr && debug) console.log("[STDERR]", stderr); 191 | setTimeout(done, WAIT); // wait a bit because it can take a little while to spawn 192 | }); 193 | }); 194 | after(function (done) { 195 | exec(bevyPath + " remove --name proxy --deploy " + api, function (err, stdout, stderr) { 196 | if (stdout && debug) console.log("[STDOUT]", stdout); 197 | if (stderr && debug) console.log("[STDERR]", stderr); 198 | done(); 199 | }); 200 | }); 201 | it("serves basic content", function (done) { 202 | request.get("http://" + testDomain + ":" + deployPort, function (err, res, body) { 203 | expect(err).to.be(null); 204 | expect(body).to.match(/Google/); 205 | done(); 206 | }); 207 | }); 208 | 209 | it("shows up in list of apps", function (done) { checkInApps("proxy", true, done); }); 210 | it("stops", function (done) { actionThenCheck(api + "app/proxy/stop", "proxy", false, done); }); 211 | it("restarts", function (done) { actionThenCheck(api + "app/proxy/start", "proxy", true, done); }); 212 | }); 213 | 214 | // XXX add deployment from the remote test repo as well 215 | // perhaps remove the dependency on express because it's slow 216 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | /*jshint es5: true */ 2 | 3 | var forever = require("forever-monitor") 4 | , proxima = require("proxima") 5 | , portfinder = require("portfinder") 6 | , express = require("express") 7 | , repo = require("./repo") 8 | , touch = require("touch") 9 | , utile = require("utile") 10 | , deepEquals = require("deep-equal") 11 | , pth = require("path") 12 | , fs = require("fs") 13 | , os = require("os") 14 | , version = require("../package.json").version 15 | , staticServerPath = pth.join(__dirname, "static-server.js") 16 | ; 17 | 18 | function Bevy (conf) { 19 | var apps = {} 20 | , spawns = {} 21 | , sessions = {} 22 | ; 23 | if (!conf) conf = {}; 24 | 25 | // load configuration 26 | var conf = utile.mixin({ 27 | domain: "localhost" 28 | , ports: [80] 29 | , store: pth.join(process.env.TMPDIR || "/var/tmp", "bevy-store") 30 | , basePort: 7000 31 | , security: "local" 32 | }, conf); 33 | if (!utile.isArray(conf.ports)) conf.ports = [conf.ports]; 34 | if (conf.uid) conf.uid = +conf.uid; 35 | if (conf.gid) conf.gid = +conf.gid; 36 | portfinder.basePort = conf.basePort; 37 | 38 | // set up proxy 39 | var proxy = proxima.Server.create() 40 | .set404(true) 41 | .set500(true) 42 | .set504(true) 43 | ; 44 | 45 | conf.ports.forEach(function (port) { 46 | if (typeof port === "string" && port.indexOf("s") === 0) { 47 | port = 1 * port.replace("s", ""); 48 | proxy.listen(port, null, true); 49 | } 50 | else proxy.listen(port); 51 | }); 52 | 53 | // dynamically adds/removes a route to the proxy 54 | function addAppRoute (domain, options) { 55 | console.log(domain, options); 56 | proxy.addRoute(domain, options); 57 | } 58 | function removeAppRoute (domain) { 59 | proxy.removeRoute(domain); 60 | } 61 | 62 | // handle errors (this needs improvement) 63 | function error (msg, obj) { 64 | console.log("[ERROR]", msg, obj); 65 | process.exit(1); 66 | } 67 | 68 | // get a free port 69 | function getPort (cb) { 70 | portfinder.getPort(function (err, port) { 71 | // next time, start the search there 72 | if (port) portfinder.basePort = port + 1; 73 | cb(err, port); 74 | }); 75 | } 76 | 77 | // launch a child 78 | function startChild (childConf) { 79 | var foreverConf = { 80 | env: { 81 | PORT: childConf.port 82 | , BEVY_ROOT: childConf.cwd 83 | , NODE_ENV: childConf.env || "development" 84 | , BEVY_CONFIG: childConf.configPath 85 | } 86 | , cwd: childConf.cwd 87 | , logFile: pth.join(childConf.logPath, "forever.log") 88 | , outFile: pth.join(childConf.logPath, "access.log") 89 | , errFile: pth.join(childConf.logPath, "error.log") 90 | }; 91 | if (conf.uid || conf.gid) foreverConf.spawnWith = {}; 92 | if (conf.uid) foreverConf.spawnWith.uid = conf.uid; 93 | if (conf.gid) foreverConf.spawnWith.gid = conf.gid; 94 | if (!fs.existsSync(childConf.cwd)) { 95 | console.log("[ERROR] Path (cwd) for app does not exist: " + childConf.cwd); 96 | return; 97 | } 98 | return new forever.Monitor(childConf.startPath, foreverConf); 99 | } 100 | 101 | // spawn an app 102 | function spawnApp (app, cb) { 103 | if (!cb) cb = function (err) { 104 | if (err) return console.log("[ERROR]" + err); 105 | }; 106 | if (app.to) { // just use proxima as a proxy 107 | addAppRoute(app.domain, app.to); 108 | app.running = true; 109 | return cb(); 110 | } 111 | getPort(function (err, port) { 112 | if (err) return cb(err); 113 | var childConf = { 114 | configPath: app.configPath 115 | , port: port 116 | , logPath: app.storePath 117 | }; 118 | if (app.static) { 119 | childConf.startPath = staticServerPath; 120 | } 121 | else { 122 | childConf.startPath = app.startPath; 123 | } 124 | childConf.cwd = app.contentPath; 125 | var child = startChild(childConf); 126 | if (!child) return cb("[ERROR] Failed to instantiate application '" + app.name + "', skipping."); 127 | spawns[app.name] = child; 128 | child.on("error", function (err) { 129 | var msg = "[ERROR] Unexpected error in app " + app.name; 130 | console.log(msg, err); 131 | cb(msg); 132 | }); 133 | child.start(); 134 | addAppRoute(app.domain, { port: port }); 135 | app.running = true; 136 | app.port = port; 137 | touch(app.runningPath, cb); 138 | }); 139 | } 140 | 141 | // stop an app 142 | function stopApp (app, cb) { 143 | if (!app.to) { 144 | var child = spawns[app.name]; 145 | if (!child) return cb(null); 146 | child.stop(); 147 | delete spawns[app.name]; 148 | } 149 | removeAppRoute(app.domain); 150 | app.running = false; 151 | if (fs.existsSync(app.runningPath)) fs.unlinkSync(app.runningPath); 152 | cb(null); 153 | } 154 | 155 | function restartApp (app, cb) { 156 | stopApp(app, function (err) { 157 | if (err) return cb(err); 158 | spawnApp(app, cb); 159 | }); 160 | } 161 | 162 | // update an app 163 | // note that this does not start an app upon installation 164 | function updateApp (app, cb) { 165 | if (!fs.existsSync(app.storePath)) fs.mkdirSync(app.storePath); 166 | // if you change the repository source, for now you have to remove and re-add 167 | // at some point we should support doing this transparently, in the meantime 168 | // the right thing to do is to tell the user that it won't work 169 | if (apps[app.name] && !deepEquals(apps[app.name].repository, app.repository)) { 170 | return cb("When changing the repository, you must remove and re-add the app."); 171 | } 172 | 173 | fs.writeFileSync(app.configPath, JSON.stringify(app, null, 4)); 174 | 175 | if (app.to) return cb(null); 176 | 177 | function restartAndOk () { 178 | // if it was running, restart it (but not otherwise) 179 | var child = spawns[app.name]; 180 | if (child) restartApp(app, cb); 181 | else cb(null); 182 | } 183 | 184 | // if app is local, no need to copy over the content 185 | if (app.repository.type === "local") { 186 | restartAndOk(); 187 | } 188 | else { 189 | var session = repo.update(app, conf, function (err) { 190 | if (err) return cb(err); 191 | cb = function () {}; // restart errors are not signalled properly 192 | restartAndOk(); 193 | }); 194 | sessions[session.id] = session; 195 | cb(null, session); 196 | } 197 | } 198 | 199 | // launch the configuration API 200 | function startAPI (port) { 201 | var app = express(); 202 | app.enable("case sensitive routing"); 203 | 204 | // middleware 205 | app.use(express.logger()); 206 | app.use(express.bodyParser()); 207 | 208 | // security 209 | var ownIPs = {} 210 | , ifaces = os.networkInterfaces() 211 | ; 212 | for (var k in ifaces) { 213 | for (var i = 0, n = ifaces[k].length; i < n; i++) { 214 | ownIPs[ifaces[k][i].address] = true; 215 | } 216 | } 217 | app.use(function (req, res, next) { 218 | if (conf.security === "none") return next(); 219 | if (!ownIPs[req.ip]) { 220 | return res.send(403, "You are not allowed to connect to this service. Logged: " + req.ip); 221 | } 222 | next(); 223 | }); 224 | 225 | // GET / 226 | // server info 227 | app.get("/version", function (req, res) { 228 | res.json({ bevy: version }); 229 | }); 230 | 231 | // GET /apps 232 | // lists all the apps 233 | app.get("/apps", function (req, res) { 234 | res.json(apps); 235 | }); 236 | 237 | // select the app 238 | function pickApp (req, res, next) { 239 | var name = req.params.name; 240 | if (!apps[name]) return res.json(404, { error: "No app for this name." }); 241 | req.bevyApp = apps[name]; 242 | next(); 243 | } 244 | 245 | // GET /app/app-name 246 | // list that app 247 | app.get("/app/:name", pickApp, function (req, res) { 248 | res.json(req.bevyApp); 249 | }); 250 | 251 | function simpleResponse (res) { 252 | return function (err) { 253 | if (err) return res.json(500, { error: err }); 254 | res.json({ ok: true }); 255 | }; 256 | } 257 | 258 | // GET /app/app-name/start 259 | // starts the app 260 | app.get("/app/:name/start", pickApp, function (req, res) { 261 | if (req.bevyApp.running) return res.json(418, { error: "App already running." }); 262 | spawnApp(req.bevyApp, simpleResponse(res)); 263 | }); 264 | 265 | // GET /app/app-name/stop 266 | // stops the app 267 | app.get("/app/:name/stop", pickApp, function (req, res) { 268 | if (!req.bevyApp.running) return res.json(418, { error: "App already stopped." }); 269 | stopApp(req.bevyApp, simpleResponse(res)); 270 | }); 271 | 272 | function sessionReponse (res) { 273 | return function (err, session) { 274 | if (err) return res.json(500, { error: err }); 275 | if (!session) return res.json({ ok: true }); 276 | res.json(202, { session: true, id: session.id, path: "/session/" + session.id }); 277 | }; 278 | } 279 | 280 | // GET /app/app-name/update 281 | // causes the source of the app to update from the repo 282 | app.get("/app/:name/update", pickApp, function (req, res) { 283 | updateApp(req.bevyApp, sessionReponse(res)); 284 | }); 285 | 286 | // PUT /app/app-name 287 | // create or update a new app, with JSON 288 | // { 289 | // environment: dev|prod|test 290 | // , domain: "foo.bast" 291 | // , dependencies: {} 292 | // , repository: { 293 | // type: "git" 294 | // , url: "..." 295 | // } 296 | // , scripts: { 297 | // start: "start-script.js" // default to app.js 298 | // } 299 | // , static: true|false 300 | // } 301 | app.put("/app/:name", function (req, res) { 302 | var name = req.params.name 303 | , desc = req.body; 304 | if (!/^[a-zA-Z0-9-_]+$/.test(name)) return res.json(400, { error: "Bad name, rule: /^[a-zA-Z0-9-_]+$/." }); 305 | if (!desc) return res.json(400, { error: "No JSON configuration provided." }); 306 | if (!desc.repository) return res.json(400, { error: "Field 'repository' required." }); 307 | if (!desc.domain) return res.json(400, { error: "Field 'domain' required." }); 308 | if (!desc.repository.type) desc.repository.type = "git"; 309 | if (!desc.static) { 310 | if (!desc.dependencies) desc.dependencies = {}; 311 | if (!desc.environment) desc.environment = "dev"; 312 | if (!desc.scripts) desc.scripts = {}; 313 | if (!desc.scripts.start) desc.scripts.start = "app.js"; 314 | } 315 | desc.running = apps[name] ? apps[name].running : false; 316 | apps[name] = desc; 317 | desc.name = name; 318 | desc.storePath = pth.join(conf.store, name); 319 | desc.configPath = pth.join(desc.storePath, "config.json"); 320 | desc.runningPath = pth.join(desc.storePath, "RUNNING"); 321 | desc.contentPath = (desc.repository.type === "local") ? desc.repository.path : pth.join(desc.storePath, "content"); 322 | if (!desc.static) desc.startPath = pth.join(desc.contentPath, desc.scripts.start); 323 | updateApp(desc, sessionReponse(res)); 324 | }); 325 | 326 | // DELETE /app/app-name 327 | // stops and deletes the app 328 | app.del("/app/:name", pickApp, function (req, res) { 329 | var app = req.bevyApp; 330 | stopApp(app, function (err) { 331 | if (err) return res.json(500, { error: "Failed to stop app, cannot remove: " + err }); 332 | utile.rimraf(app.storePath, function (err) { 333 | if (err) return res.json(500, { error: "Failed to remove app: " + err }); 334 | delete apps[app.name]; 335 | res.json({ ok: true }); 336 | }); 337 | }); 338 | }); 339 | 340 | // GET /session/:id 341 | // returns the last messages of a session for a long-running job (e.g npm) 342 | // this is meant for polling 343 | app.get("/session/:id", function (req, res) { 344 | var id = req.params.id 345 | , session = sessions[id] 346 | ; 347 | if (!session) return res.json(404, { error: "No such running session." }); 348 | if (session === "done") return res.json({ done: true }); 349 | res.json({ messages: session.messages() }); 350 | if (session.done) sessions[id] = "done"; 351 | }); 352 | 353 | app.listen(port); 354 | 355 | // load existing apps from store 356 | // the store is created if it doesn't exist 357 | // inside the store, each directory that contains a "config.json" is an app 358 | // if the app directory contains a "RUNNING" file, then it's running 359 | // the app directory has a content directory that's the app's content and has a name that 360 | // depends on the repo type 361 | if (!fs.existsSync(conf.store)) fs.mkdirSync(conf.store); 362 | fs.readdir(conf.store, function (err, files) { 363 | if (err) return error("Failed to read directory " + conf.store, err); 364 | files.forEach(function (name) { 365 | var dir = pth.join(conf.store, name); 366 | fs.stat(dir, function (err, stat) { 367 | if (err) return error("Failed to stat directory " + dir, err); 368 | if (!stat.isDirectory()) return; 369 | var configPath = pth.join(dir, "config.json") 370 | , runningPath = pth.join(dir, "RUNNING") 371 | , running = fs.existsSync(runningPath); 372 | if (!fs.existsSync(configPath)) return; 373 | fs.readFile(configPath, function (err, data) { 374 | if (err) return error("Could not read app configuration " + configPath, err); 375 | var appConfig; 376 | try { 377 | appConfig = JSON.parse(data); 378 | } 379 | catch (e) { 380 | return error("Failed to parse app configuration" + configPath, err); 381 | } 382 | apps[name] = appConfig; 383 | if (running) spawnApp(appConfig); 384 | }); 385 | }); 386 | }); 387 | console.log("Bevy up, management API available on port(s) ", conf.ports); 388 | }); 389 | } 390 | 391 | // add the local service as an app 392 | getPort(function (err, port) { 393 | if (err) return error("Failed to assign port", err); 394 | addAppRoute(conf.domain, { port: port }); 395 | startAPI(port); 396 | }); 397 | } 398 | 399 | exports.run = function (conf) { 400 | return new Bevy(conf); 401 | }; 402 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Bevy - A simple server to manage multiple Node services 2 | ======================================================= 3 | 4 | [![NPM version](https://badge.fury.io/js/bevy.png)](http://badge.fury.io/js/bevy) 5 | 6 | 7 | I love Node, but I have often found deployment to be more painful than it could be. One typically 8 | has to somehow upload the new code, stop the old, restart it, make sure there's a properly 9 | configured proxy in front of it in order to serve off port 80 for a given domain, etc. It's all 10 | surmountable, of course, but it's all pretty repetitive too. 11 | 12 | The basic principle of Bevy is simple. It runs as a single proxy service for all of your Node 13 | applications, possibly directly on port 80 so that you don't even have to worry about a world-facing 14 | proxy if you don't want to (Bevy comes with a built-in static file server so that you can also use 15 | it for purely static content, as well as with the ability to proxy to arbitrary third-party services 16 | should you need that as an escape hatch). This proxy also exposes a simple REST API that receives 17 | configuration commands that allow it to install, remove, start, stop, list, and describe the 18 | applications that Bevy is running for you. It knows how to fetch an app's content from either git or 19 | a local directory, and it knows how to run npm in order to install dependencies from your 20 | repository. 21 | 22 | So the idea is this: once you have bevy up and running on a machine (which is trivial and only 23 | requires minimal configuration), all you need to deploy your Node apps is a tiny bit of extra 24 | configuration and a simple command line call. 25 | 26 | Bevy works with HTTP, HTTPS, and Web Sockets. 27 | 28 | Installing Bevy 29 | --------------- 30 | 31 | You probably want to install it globally: 32 | 33 | npm install -g bevy 34 | 35 | In order to run the Bevy server reliably, you likely want to use ```forever``` (but you don't have 36 | to if you prefer to use something else). For that: 37 | 38 | npm install -g forever 39 | 40 | And that's it. 41 | 42 | Examples 43 | -------- 44 | 45 | I strongly recommend you get familiar with the entirety of this document before you deploy Bevy, 46 | but if you want to play with simple examples to get a feel for it you can simply look in the 47 | examples directory. 48 | 49 | Running the Bevy server 50 | ----------------------- 51 | 52 | The Bevy server is the part responsible for both managing your apps and for proxying to them. You 53 | run it thus: 54 | 55 | bevy-server 56 | 57 | If you want to run Bevy as a permanent daemon, it is recommend that you start it with ```forever```: 58 | 59 | forever start bevy-server 60 | 61 | Bevy does however require a few configuration parameters in order to work. These can either be 62 | specified on the command line, in ```/etc/bevy/config.json```, or in a JSON configuration file 63 | provided using the ```-f``` option. The configuration parameters (including JSON keys where 64 | applicable) are as follows: 65 | 66 | 67 | * ```-h```, ```--help```: Show this usage. 68 | * ```-f path```, ```--config path```: The path to a configuration file to use, possibly relative. 69 | * ```domain```, ```-d```, ```--domain```: The domain to use for the deployment service (i.e. the 70 | REST API which Bevy exposes, not for the services being proxied to — those are set up by the 71 | client). Bevy listens to all incoming requests on its given ports, but one of those domains has to 72 | be assigned to the service that it exposes to manage the apps it is running. Defaults to 73 | localhost. 74 | * ```ports```, ```-p```, ```--ports```: The port on which to listen for requests to proxy. Note that 75 | several can be specified (using an array in JSON, and repeated options on the command line). It will 76 | listen to all of the provided ports and proxy in the same way for all (except that secure ports only 77 | trigger on HTTPS and the rest only on HTTP). If you wish to listen to a secure port for HTTPS, then 78 | prefix it with "s". Defaults to [80]. 79 | * ```store```, ```-s```, ```--store```: The directory in which Bevy will store the apps that it 80 | manages. Note that this needs to be writable by Bevy. Defaults to a directory called ```bevy-store``` 81 | in either your ```$TMPDIR``` or ```/var/tmp```. It is **strongly** recommended to set this to 82 | another value as you typically want it properly persisted. 83 | * ```security```, ```--security```: The default security setup for Bevy is to only accept 84 | connections to its management API coming from the local machine, corresponding to the 85 | value ```local```. This can be set to ```none``` to disable this check. **BE VERY CAREFUL** as this 86 | effectively enables anyone who can reach the server to install and run arbitrary software on the 87 | machine. 88 | * ```uid```, ```-u```, ```--uid```: The user id under which to run spawned processes. If you are 89 | running Bevy as root (which is required on many platforms in order to be able to listen on ports 90 | lower than 1024) then it is highly recommended to set this option to a user with lower privileges. 91 | Otherwise not only will the spawned services be running as root, but also git and npm, as well as 92 | whatever script npm runs. Note that due to limitations in Node's API this has to be the numeric 93 | uid (use ```id -u username``` to get it). 94 | * ```gid```, ```-g```, ```--gid```: Same as the previous one, but for the group id. Note that due to 95 | limitations in Node's API this has to be the numeric gid (use ```id -g username``` to get it). 96 | 97 | 98 | 99 | An example configuration file: 100 | 101 | { 102 | "domain": "deploy.example.net" 103 | , "ports": [80, "s443"] 104 | , "store": "/users/bevy/store/" 105 | , "uid": 501 106 | , "gid": 20 107 | } 108 | 109 | The same on the command line: 110 | 111 | forever start bevy-server -d deploy.example.net -p 80 -p s443 -s /users/bevy/store/ \ 112 | -u 501 -g 20 113 | 114 | You can mix and match the configuration file and command line parameters; the latter will take 115 | priority. 116 | 117 | Deploying an app with Bevy 118 | -------------------------- 119 | 120 | In order to deploy an application with Bevy you use the ```bevy``` command. This command can 121 | deploy, remove, start, and stop bevy apps in a given Bevy server. It gets its information from the 122 | app's ```package.json``` file, optionally supplemented by information in a similar ```bevy.json``` 123 | file or on the command line. 124 | 125 | It takes the following fields into account: 126 | 127 | * ```deploy```: The URL (including scheme and port) of the Bevy server to deploy to. Required. 128 | * ```name```: This is the standard ```package.json``` name field; Bevy uses this to provide your 129 | app with a unique ID on the server. Required. 130 | * ```domain```: The domain (just the host) at which you wish to have your app reachable. If the 131 | domain begins and ends with "/" it is interpreted as a regular expression; otherwise it's a glob. In 132 | globs, the * character matches any number of characters with no restrictions, and the ? character 133 | matches everything except separators (. or :). 134 | * ```dependencies```: This is the standard ```package.json``` dependencies field; Bevy uses this to 135 | tell npm what to install. Defaults to none. 136 | * ```static```: A boolean that when true indicates that this project is actually static so that no 137 | Node app should be run. This is useful in case you have a shared server running lots of little sites 138 | some of which are static, and you don't want to set up a proxy in front of Bevy. 139 | * ```repository```: This is an object that specifies where to get the content for the app. Required. 140 | It supports the following fields: 141 | * ```type```: This has to be ```git``` or ```local```. 142 | * ```url```: Applies to type ```git```, provides a Git URL or path for the repository. Required. 143 | * ```branch```: Applies to type ```git```, indicates which branch to use. Defaults to whatever the 144 | default branch in that repository is. 145 | * ```path```: Applies to type ```local```, gives the file system path to use. Note that when an 146 | app is both local, Bevy will not copy the files over but rather serve directly from that directory. 147 | This includes not running npm to install dependencies; if you're pointing at a local directory it is 148 | up to you to do so (the primary use for ```local``` is development, where this is what you expect). 149 | * ```scripts```: This is the standard ```package.json``` scripts object. Bevy uses its ```start``` 150 | field to know which application to start. Defaults to ```app.js```. 151 | * ```directoryIndex```: Applies only to static servers, this provides a list of file names to use 152 | to select the directory index. It defaults to ```index.html```. 153 | * ```to```: This option enables Bevy to simply act as a proxy to a remote service that it does not 154 | manage. The format is that of 155 | [proxima's endpoints](https://github.com/BlueJeansAndRain/proxima#configuration-endpoints). 156 | 157 | The way Bevy obtains that information is as follows: 158 | 159 | 1. It reads the ```package.json``` file, if any (highly recommended). 160 | 2. It reads the ```bevy.json``` file, if any. The values found there overwrite existing ones. This 161 | makes it possible to keep your Bevy-specific information out of ```package.json``` if it is used 162 | for other things as well, or for instance if you want to use a different ```name``` in each. 163 | 3. If there was a ```bevy.json``` file, and it contained a key matching the selected environment 164 | (i.e. ```development``` or ```production```) then it will take the values there and overwrite the 165 | existing ones. Typically this can be used to select a different deployment server for different 166 | environments. 167 | 4. If there were command line parameters, they override the values found up to here. 168 | 169 | The general syntax of the ```bevy``` command is: 170 | 171 | bevy action [options] 172 | 173 | The actions are: 174 | 175 | * ```deploy```: Deploys the app. This installs it if it wasn't installed, updates it otherwise, then 176 | starts it. 177 | * ```start```: Starts the app. 178 | * ```stop```: Stops the app. 179 | * ```remove```: Removes the app. Note that this can be somewhat destructive, it will remove logs 180 | (as well as anything that your app may have stored under its running directory). 181 | * ```stage```: Deploys the app using the configuration for the "development" environment but setting 182 | the runtime environment to "production". This allows you to run code on your development deployment 183 | under production conditions. 184 | * ```help```: Get help. 185 | 186 | The options, which must come after the action, are the following: 187 | 188 | 189 | * ```--package```: The path to the ```package.json``` to load. Defaults to the current directory. 190 | * ```--bevy```: The path to the ```bevy.json``` to load. Defaults to the current directory. 191 | * ```--env```: The environment under which to run. Defaults to development. 192 | * ```--deploy```: Same as ```deploy``` in JSON. 193 | * ```--name```: Same as ```name``` in JSON. 194 | * ```--domain```: Same as ```domain``` in JSON. 195 | * ```--static```: A flag, same as ```static``` in JSON. 196 | * ```--type```: Same as ```repository.type``` in JSON. 197 | * ```--url```: Same as ```repository.url``` in JSON. 198 | * ```--branch```: Same as ```repository.branch``` in JSON. 199 | * ```--path```: Same as ```repository.path``` in JSON. 200 | * ```--start```: Same as ```scripts.start``` in JSON. 201 | * ```---to```: This has the same function as ```to``` in JSON, but with a different syntax (since in 202 | JSON it uses a structured object). If it's a number, that's the port. If it starts with "/", then 203 | it's a path. Otherwise it expects it to be a host:port pair (without any scheme, as in 204 | 127.0.0.1:8043). 205 | * ```--secure```: Same as ```to.secure``` in JSON. 206 | 207 | 208 | 209 | 210 | Deploying Bevy Securely 211 | ----------------------- 212 | 213 | Bevy is a system that allows you to install and run software that can perform arbitrary operations, 214 | over the network. Read that again. Make sure you get this. This section isn't something you want to 215 | read in the future, as a nice-to-have, feel-good extra. You **have** to read it. 216 | 217 | By default, Bevy's app management API only accepts connections coming from an IP on the local 218 | machine. On the face of it, this makes the API a whole lot less useful if you're on your 219 | development machine and want to deploy to production. One simple way of doing that is explained 220 | further below. 221 | 222 | Note that there is an option to disable this security check entirely. It is there so that people who 223 | know what they are doing can do so. For instance, you could consider using it on a machine that is 224 | well protected inside your own network. But only do so **very** carefully. Note that binding the 225 | API to ```localhost``` is not enough to protect you; if I know the IP I can still reach it and 226 | specify ```Host: localhost``` to fool the server. 227 | 228 | The simplest way to set Bevy up on a production, world-accessible server is to: 229 | 230 | 1. Stick to ```localhost``` (or whatever local domain) for the server. 231 | 2. Keep the above security check on (it is by default). 232 | 3. Use an SSH tunnel from your development box to the server. That's pretty easy. 233 | 234 | The way in which you set up an SSH tunnel is as follows (assuming you already have SSH access to 235 | the server). Run: 236 | 237 | ssh -f your-user@your-server.com -L 2000:your-server.com:80 -N 238 | 239 | What the above does is that it creates a tunnel from ```localhost:2000``` to 240 | ```your-server.com:80``` through an SSH connection that identifies you as ```your-user``` to the 241 | server. You can naturally change your user, the remote server, and the ports you use. You can save 242 | that command, run it at start up, etc. 243 | 244 | With the above setup, your deployment target simply becomes ```http://localhost:2000/```. When Bevy 245 | talks to that URL, it will be talking to the remote server. 246 | 247 | Another important security-related aspect to take into account are the uid/gid settings. As 248 | explained in the configuration section, if these are unset and you are running as root (which is 249 | often required), then not only spawned services but also git and npm will run as root. Needless to 250 | say, this can be a large attack vector. 251 | 252 | Bevy and HTTPS 253 | -------------- 254 | 255 | Bevy dispatches HTTPS connections based on SNI. The advantage here is that you do not need to muck 256 | with certs at the Bevy level and only set that up in your application — proxying will just work. The 257 | downside is that SNI is not supported in some old clients (XP, IE less than 7, Android browser less 258 | than 3). For those, either provide an HTTP endpoint, or send in a pull request to support HTTPS 259 | more directly. 260 | 261 | Bevy and Web Sockets 262 | -------------------- 263 | 264 | Bevy uses [proxima](https://github.com/BlueJeansAndRain/proxima/) under the hood and so mirrors its 265 | support for Web Sockets. Essentially, since the negotiation phase in the WS protocol is HTTP-based, 266 | Bevy simply proxies based on that and afterwards the connection should be transparently relayed. 267 | 268 | REST API 269 | -------- 270 | 271 | The REST API exposed to manage applications would be better described as an HTTP API because it's 272 | not in fact all that RESTful. Where it made sense, I elected to go with simplicity of interaction 273 | (e.g. just entering a URL in the browser bar) over "correctness". I don't believe that anything is 274 | lost here, except perhaps RESTafarian brownie points. I can live without those. 275 | 276 | All interactions involve JSON. 277 | 278 | ### GET /version 279 | Provides the server information. Mostly useful to check that it's running. 280 | Always returns the version: 281 | 282 | { bevy: "0.2.42" } 283 | 284 | ### GET /apps 285 | Lists all the apps, keyed by name. The value for each is an object describing the application that 286 | corresponds to the app's configuration as provided during deployment (typically, as resolved 287 | by ```bevy```). Additionally, it contains a ```running``` boolean indicating whether the app is 288 | running or not, a number of paths that are used in running the app, and the port that it uses. Apart 289 | from ```running```, you shouldn't need any of that information but it can come in handy for 290 | debugging purposes. 291 | 292 | Example response: 293 | 294 | { 295 | "first-test": { 296 | "name": "first-test", 297 | "version": "0.0.1", 298 | "domain": "*.first-test.local", 299 | "dependencies": { 300 | "express": "*", 301 | "eyes": "*" 302 | }, 303 | "repository": { 304 | "type": "git", 305 | "url": "/Projects/bevy/scratch/one" 306 | }, 307 | "scripts": { 308 | "start": "app.js" 309 | }, 310 | "environment": "dev", 311 | "running": true, 312 | "storePath": "/var/folders/p4/1wzy444j5tbg__5kj1nt8lxr0000gn/T/bevy-store/first-test", 313 | "configPath": "/var/folders/p4/1wzy444j5tbg__5kj1nt8lxr0000gn/T/bevy-store/first-test/config.json", 314 | "runningPath": "/var/folders/p4/1wzy444j5tbg__5kj1nt8lxr0000gn/T/bevy-store/first-test/RUNNING", 315 | "contentPath": "/var/folders/p4/1wzy444j5tbg__5kj1nt8lxr0000gn/T/bevy-store/first-test/content", 316 | "startPath": "/var/folders/p4/1wzy444j5tbg__5kj1nt8lxr0000gn/T/bevy-store/first-test/content/app.js", 317 | "port": 7001 318 | } 319 | } 320 | 321 | ### GET /app/:name 322 | Returns the same information as the previous operation, but just for one app with the given name. 323 | 324 | If the app is not found, it returns a 404 with: 325 | 326 | { error: "No app for this name." } 327 | 328 | If successful, it will return the same JSON as above, below the corresponding app name key. 329 | 330 | ### GET /app/:name/start 331 | Starts the app. 332 | 333 | If the app is not found, it returns a 404 with: 334 | 335 | { error: "No app for this name." } 336 | 337 | If the app was already running, it returns a 418 with: 338 | 339 | { error: "App already running." } 340 | 341 | For all other errors, it returns a 500 with the ```error``` field set to whatever error the 342 | system provided. 343 | 344 | ### GET /app/:name/stop 345 | Stops the app 346 | 347 | If the app is not found, it returns a 404 with: 348 | 349 | { error: "No app for this name." } 350 | 351 | If the app was already running, it returns a 418 with: 352 | 353 | { error: "App already stopped." } 354 | 355 | For all other errors, it returns a 500 with the ```error``` field set to whatever error the 356 | system provided. 357 | 358 | ### GET /app/:name/update 359 | Causes the source of the app to update from the repo (pull it through git, or copying files), and 360 | the app to then be restarted. This can be quite long, especially if npm installs a number of new 361 | dependencies. 362 | 363 | If the app is not found, it returns a 404 with: 364 | 365 | { error: "No app for this name." } 366 | 367 | For all other errors, it returns a 500 with the ```error``` field set to whatever error the 368 | system provided. 369 | 370 | The success response for this method can take two forms. If the app is a simple local application 371 | that does not require a possibly long-running operation (such as git cloning or npm install) then 372 | it will immediately reply with status ```200 OK``` and a ```{ "ok": true }``` as the body. 373 | 374 | If however the request involves a long running process, then it will reply with status 375 | ```202 Accepted```. The response body will be JSON similar to the following: 376 | 377 | { 378 | "session": true 379 | , "id": "PTBjY3J3tM" 380 | , "path": "/session/PTBjY3J3tM" 381 | } 382 | 383 | The ```id``` (or the ```path```) can then be used with the session API described below in order to 384 | poll the server about its progress in carrying out the update. 385 | 386 | ### PUT /app/:name 387 | Create or update the configuration of an app. The body of the request must be the desired 388 | configuration (as described in the previous section). 389 | 390 | If the app was already running, then it is restarted. However, if it was not, or if this is a fresh 391 | install, then the application is **not** started. (The command line tool does that for you on 392 | install, though.) 393 | 394 | If the name does not match ```/^[a-zA-Z0-9-_]+$/```, it returns a 400 with: 395 | 396 | { error: "Bad name, rule: /^[a-zA-Z0-9-_]+$/." } 397 | 398 | If no configuration is provided, it returns a 400 with: 399 | 400 | { error: "No JSON configuration provided." } 401 | 402 | If the ```repository``` or ```domain``` fields are missing, it returns a 400 with: 403 | 404 | { error: "Field 'XXX' required." } 405 | 406 | For all other errors, it returns a 500 with the ```error``` field set to whatever error the 407 | system provided. 408 | 409 | The response is the same as for the previous method. 410 | 411 | ### DELETE /app/:name 412 | Stops and deletes the app. 413 | 414 | If the app is not found, it returns a 404 with: 415 | 416 | { error: "No app for this name." } 417 | 418 | If it fails, it returns a 500 with one of: 419 | 420 | { error: "Failed to stop app, cannot remove: REASON" } 421 | { error: "Failed to remove app: REASON" } 422 | 423 | ### GET /session/:id 424 | Query as specific session corresponding to a long-running backend process (npm, git). This is 425 | used by the client in order to poll ongoing progress. Several different responses can be received: 426 | 427 | If there is no such session, a 404 error. Note that when a session terminates, it will continue to 428 | respond with 200 until the Bevy server is restarted. This means that you should never get a 404 out 429 | of your polling, even when the session has terminated (unless the server has been restarted, which 430 | is unlikely). 431 | 432 | If there is such a session, the response is 200 with a body that depends on the status of the 433 | session. 434 | 435 | If the session is finished, you just receive ```{ done: true }```. If it is running, you will get 436 | ```{ messages: [array, of, messages]}```. 437 | 438 | Each message is an array with a type as its first value and optionally a string as its second value. 439 | If the key is ```progress```, it is a progress message and the accompanying string will be a human 440 | description of the progress event. If the key is ```error```, it is an error message and the 441 | accompanying string will be whatever error message could be gathered. Errors typically lead to the 442 | termination of the session. Finally, if the key is ```end```, there is no accompanying string and 443 | it indicates that this will be the last message of the session (it will soon be flagged as done if 444 | it hasn't already). 445 | 446 | Note that whenever you ask for a session, the messages that are returned to you are removed from the 447 | queue of messages that Bevy is maintaining. Therefore, you will never get the same message twice and 448 | can safely just display them when polling multiple times without being concerned that you may show 449 | a given message more than once. 450 | 451 | It is possible for the array of messages to be empty if nothing at all has happened since you last 452 | polled. 453 | --------------------------------------------------------------------------------