├── 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 | [](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 |
--------------------------------------------------------------------------------