@martinfranek/naught
Version:
zero downtime deployment for your node.js server
586 lines (532 loc) • 18.3 kB
JavaScript
var fs = require('fs');
var os = require('os');
var net = require('net');
var assert = require('assert');
var json_socket = require('./json_socket');
var spawn = require('child_process').spawn;
var path = require('path');
var DEFAULT_IPC_FILE = 'naught.ipc';
var DEFAULT_PID_FILE = 'naught.pid';
var CWD = process.cwd();
var daemon = require('./daemon');
var packageJson = require('../package.json');
var cmds = {
start: {
help: "naught start [options] server.js [script-options]\n\n" +
" Starts server.js as a daemon passing script-options as command\n" +
" line arguments.\n\n" +
" Each worker's stdout and stderr are redirected to a log files\n" +
" specified by the `stdout` and `stderr` parameters. When a log file\n" +
" becomes larger than `max-log-size`, the log file is renamed using the\n" +
" current date and time, and a new log file is opened.\n\n" +
" With naught, you can use `console.log` and friends. Because naught\n" +
" pipes the output into a log file, node.js treats stdout and stderr\n" +
" as asynchronous streams.\n\n" +
" If you don't want a particular log, use `/dev/null` for the path. Naught\n" +
" special cases this filename and disables that log altogether.\n\n" +
" When running in `daemon-mode` `false`, naught will start the master\n" +
" process and then block. It listens to SIGHUP for restarting and SIGTERM\n" +
" for stopping. In this situation you may use `-` for `stderr` and/or\n" +
" `stdout` which will redirect the respective streams to naught's output\n" +
" streams instead of a log file.\n\n" +
" Creates an `ipc-file` which naught uses to communicate with your\n" +
" server once it has started.\n\n" +
" Available options and their defaults:\n\n" +
" --worker-count 1\n" +
" --ipc-file " + DEFAULT_IPC_FILE + "\n" +
" --pid-file " + DEFAULT_PID_FILE + "\n" +
" --log naught.log\n" +
" --stdout stdout.log\n" +
" --stderr stderr.log\n" +
" --max-log-size 10485760\n" +
" --cwd " + CWD + "\n" +
" --daemon-mode true\n" +
" --remove-old-ipc false\n" +
" --node-args ''",
fn: function(argv){
var options = {
'worker-count': '1',
'ipc-file': DEFAULT_IPC_FILE,
'pid-file': DEFAULT_PID_FILE,
'log': 'naught.log',
'stdout': 'stdout.log',
'stderr': 'stderr.log',
'max-log-size': '10485760',
'cwd': CWD,
'daemon-mode': 'true',
'remove-old-ipc': 'false',
'node-args': '',
};
var arr = chompArgv(options, argv)
, err = arr[0]
, script = arr[1];
if (!err && script != null) {
options['daemon-mode'] = options['daemon-mode'] === 'true';
options['remove-old-ipc'] = options['remove-old-ipc'] === 'true';
options['worker-count'] = extendedWorkerCount(options['worker-count']);
if (isNaN(options['worker-count'])) return false;
options['max-log-size'] = parseInt(options['max-log-size'], 10);
if (isNaN(options['max-log-size'])) return false;
startScript(options, script, argv);
return true;
} else {
return false;
}
}
},
stop: {
help: "naught stop [options] [ipc-file]\n\n" +
" Stops the running server which created `ipc-file`.\n" +
" Uses `" + DEFAULT_IPC_FILE + "` by default.\n\n" +
" This sends the 'shutdown' message to all the workers and waits for\n" +
" them to exit gracefully.\n\n" +
" If you specify a timeout, naught will forcefully kill your workers\n" +
" if they do not shut down gracefully within the timeout.\n\n" +
" Available options and their defaults:\n\n" +
" --timeout none\n" +
" --pid-file " + DEFAULT_PID_FILE,
fn: function(argv){
var options = {
'timeout': 'none',
'pid-file': DEFAULT_PID_FILE
};
var arr = chompArgv(options, argv)
, err = arr[0]
, ipcFile = arr[1] || DEFAULT_IPC_FILE;
if (!err && argv.length === 0) {
options.timeout = parseFloat(options.timeout);
if (isNaN(options.timeout)) {
options.timeout = null;
}
stopScript(options, ipcFile);
return true;
} else {
return false;
}
}
},
status: {
help: "naught status [ipc-file]\n\n" +
" Displays whether a server is running or not.\n" +
" Uses `" + DEFAULT_IPC_FILE + "` by default.",
fn: function(argv){
if (argv.length > 1) {
return false;
}
var ipcFile = argv[0] || DEFAULT_IPC_FILE;
displayStatus(ipcFile);
return true;
}
},
deploy: {
help: "naught deploy [options] [ipc-file]\n\n" +
" Replaces workers with new workers using new code and optionally\n" +
" the environment variables from this command.\n\n" +
" Naught spawns all the new workers and waits for them to all become\n" +
" online before killing a single old worker. This guarantees zero\n" +
" downtime if any of the new workers fail and provides the ability to\n" +
" cleanly abort the deployment if it hangs.\n\n" +
" A hanging deploy happens when a new worker fails to emit the 'online'\n" +
" message, or when an old worker fails to shutdown upon receiving the\n" +
" 'shutdown' message. A keyboard interrupt will cause a deploy-abort,\n" +
" cleanly and with zero downtime.\n\n" +
" If `timeout` is specified, naught will automatically abort the deploy\n" +
" if it does not finish within those seconds.\n\n" +
" If `override-env` is true, the environment varibables that are set with\n" +
" this command are used to override the original environment variables\n" +
" used with the `start` command. If any variables are missing, the\n" +
" original values are left intact.\n\n" +
" `worker-count` can be used to change the number of workers running. A\n" +
" value of `0` means to keep the same number of workers.\n" +
" A value of 'auto', will set value as per the number of available CPUs.\n\n" +
" `cwd` can be used to change the cwd directory of the master process.\n" +
" This allows you to release in different directories. Unfortunately,\n" +
" this option doesn't update the script location. For example, if you\n" +
" start naught `naught start --cwd /release/1 server.js` and deploy\n" +
" `naught deploy --cwd /release/2` the script file will not change from\n" +
" '/release/1/server.js' to '/release/2/server.js'. You have to create\n" +
" a symlink and pass the full symlink path to naught start\n" +
" '/current/server.js'. After creating the symlink naught starts the\n" +
" correct script, but the cwd is still old and require loads files from\n" +
" from the old directory. The cwd option allows you to update the cwd\n" +
" to the new directory. It defaults to naught's cwd.\n\n" +
" Uses `" + DEFAULT_IPC_FILE + "` by default.\n\n" +
" Available options and their defaults:\n\n" +
" --worker-count 0\n" +
" --override-env true\n" +
" --timeout none\n" +
" --cwd " + CWD,
fn: function(argv){
var options = {
'worker-count': 0,
'override-env': 'true',
'timeout': 'none',
'cwd': CWD
};
var arr = chompArgv(options, argv)
, err = arr[0]
, ipcFile = arr[1] || DEFAULT_IPC_FILE;
if (!err && argv.length === 0) {
options['override-env'] = options['override-env'] === 'true';
options.timeout = parseFloat(options.timeout);
if (isNaN(options.timeout)) options.timeout = null;
options['worker-count'] = parseInt(options['worker-count'], 10);
if (isNaN(options['worker-count'])) return false;
deploy(options, ipcFile);
return true;
} else {
return false;
}
}
},
'deploy-abort': {
help: "naught deploy-abort [ipc-file]\n\n" +
" Aborts a hanging deploy. A hanging deploy happens when a new worker\n" +
" fails to emit the 'online' message, or when an old worker fails\n" +
" to shutdown upon receiving the 'shutdown' message.\n\n" +
" When deploying, a keyboard interrupt will cause a deploy-abort,\n" +
" so the times you actually have to run this command will be few and\n" +
" far between.\n\n" +
" Uses `" + DEFAULT_IPC_FILE + "` by default.",
fn: function(argv){
if (argv.length > 1) return false;
var ipcFile = argv[0] || DEFAULT_IPC_FILE;
deployAbort(ipcFile);
return true;
}
},
version: {
help: "naught version\n\n" +
" Prints the version of naught and exits.",
fn: function(argv) {
if (argv) {
if (argv.length > 0) return false;
console.log(packageJson.version);
return true;
}
},
},
help: {
help: "naught help [cmd]\n\n" +
" Displays help for cmd.",
fn: function(argv){
var cmd;
if (argv.length === 1 && (cmd = cmds[argv[0]]) != null) {
console.log(cmd.help);
} else {
printUsage();
}
return true;
}
}
};
var cmd = cmds[process.argv[2]]
if (cmd) {
if (!cmd.fn(process.argv.slice(3))) {
console.error(cmd.help);
}
} else {
printUsage();
process.exit(1);
}
function chompArgv(obj, argv){
while (argv.length) {
var arg = argv.shift();
if (arg.indexOf('--') === 0) {
var argName = arg.substring(2);
if (!(argName in obj)) {
return [new Error('InvalidArgument'), null];
}
if (argv.length === 0) {
return [new Error('MissingArgument'), null];
}
obj[argName] = argv.shift();
} else {
return [null, arg];
}
}
return [null, null];
}
function connectToDaemon(socket_path, cbs){
var socket = net.connect(socket_path, cbs.ready);
json_socket.listen(socket, cbs.event);
return socket;
}
function assertErrorIsFromInvalidSocket(error){
if (error.code !== 'ENOENT') {
throw error;
}
}
function exitWithConnRefusedMsg(socket_path){
fs.writeSync(process.stderr.fd,
"unable to connect to ipc-file `" + socket_path + "`\n\n" +
"1. the ipc file specified is invalid, or\n" +
"2. the daemon process died unexpectedly\n");
process.exit(1);
}
function getDaemonMessages(socket_path, cbs){
var socket = connectToDaemon(socket_path, cbs);
socket.on('error', function(error){
if (error.code === 'ENOENT') {
fs.writeSync(process.stderr.fd, "server not running\n");
if(cbs.serverNotRunning) {
// Caller wants to handle the condition of server not running,
// so let them do it.
cbs.serverNotRunning();
} else {
// Caller isn't prepared to deal with server not running, so die.
process.exit(1);
}
} else if (error.code === 'ECONNREFUSED') {
exitWithConnRefusedMsg(socket_path);
} else {
throw error;
}
});
return socket;
}
function printUsage(){
for (var name in cmds) {
var cmd = cmds[name];
console.error("\n" + cmd.help + "\n");
}
}
function startScript(options, script, argv){
var ipcFile = options['ipc-file'];
var socket = connectToDaemon(ipcFile, {
ready: function(){
json_socket.send(socket, {
action: 'NaughtStatus'
});
},
event: function(msg){
if (msg.event === 'Status') {
fs.writeSync(process.stdout.fd, statusMsg(msg));
process.exit(1);
} else {
printDaemonMsg(msg);
}
}
});
socket.on('error', onSocketError);
function onSocketError(error) {
socket.end();
if (error.code === 'ECONNREFUSED') {
if(options['remove-old-ipc']) {
fs.writeSync(process.stderr.fd,
"unable to connect to ipc-file `" + ipcFile + "`\n\n" +
"removing the ipc-file and attempting to continue\n");
fs.unlinkSync(ipcFile);
startDaemon();
} else {
exitWithConnRefusedMsg(ipcFile);
}
} else if (error.code === 'ENOENT') {
// no server running
startDaemon();
} else {
throw error;
}
}
function startDaemon(){
var args = [
options['worker-count'],
path.resolve(CWD, ipcFile),
resolveLogPath(options.log),
resolveLogPath(options.stderr),
resolveLogPath(options.stdout),
options['max-log-size'],
path.resolve(CWD, script),
options['node-args'],
options['pid-file']
].concat(argv);
if (options['daemon-mode']) {
startDaemonChild(args);
} else {
startBlockingMaster(args);
}
}
function startBlockingMaster(args) {
daemon.start(args);
}
function startDaemonChild(args) {
var modulePath = path.resolve(__dirname, "start_daemon.js");
var child = spawn(process.execPath, [modulePath].concat(args), {
env: process.env,
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
detached: true,
cwd: options.cwd,
});
child.unref();
child.on('message', function(msg){
if (msg.event === 'Error') {
fs.writeSync(process.stderr.fd,
"unable to start daemon: " + msg.value + "\n");
process.exit(1);
} else if (msg.event === 'IpcListening') {
child.disconnect();
var sentShutdown = false;
var socket = connectToDaemon(ipcFile, {
event: function(msg){
if (msg.event === 'Ready') {
process.stdout.write(statusMsg(msg));
socket.end();
} else if (msg.event === 'Shutdown') {
console.error("The server crashed without booting. Check stderr.log.");
socket.end();
process.exit(1);
} else {
printDaemonMsg(msg);
}
}
});
} else {
throw new Error("unexpected message from daemon");
}
});
}
}
function resolveLogPath(logPath) {
if (logPath === '-') {
return '-';
} else {
return path.resolve(CWD, logPath);
}
}
function stopScript(options, ipcFile){
var socket = getDaemonMessages(ipcFile, {
ready: function(){
json_socket.send(socket, {
action: 'NaughtShutdown',
timeout: options.timeout
});
},
event: function(msg){
if (msg.event === 'Shutdown') {
socket.end();
} else if (msg.event === 'AlreadyShuttingDown') {
console.error("Waiting for shutdown already in progress.");
console.error("If it hangs, Ctrl+C this command and try it again with --timeout 1");
} else {
printDaemonMsg(msg);
}
},
serverNotRunning: function() {
// The server isn't running.
// Who cares, we're trying to stop it anyway, this is not an error.
}
});
}
function workerCountsFromMsg(msg){
if (!msg.count) return "Unknown";
return "booting: " + msg.count.booting +
", online: " + msg.count.online +
", dying: " + msg.count.dying +
", new_online: " + msg.count.new_online;
}
function printDaemonMsg(msg){
console.error(msg.event + ". " + workerCountsFromMsg(msg));
}
function statusMsg(msg){
if (msg.count.booting > 0) {
return "booting\n" + workerCountsFromMsg(msg) + "\n";
} else if (msg.waiting_for === 'shutdown') {
return "shutting down\n" + workerCountsFromMsg(msg) + "\n";
} else if (msg.waiting_for != null) {
return "deploy in progress\n" + workerCountsFromMsg(msg) + "\n";
} else {
return "workers online: " + msg.count.online + "\n";
}
}
function deploy(options, ipcFile){
var socket = getDaemonMessages(ipcFile, {
ready: function(){
setAbortKeyboardHook();
json_socket.send(socket, {
action: 'NaughtDeploy',
newWorkerCount: options['worker-count'],
environment: options['override-env'] ? process.env : {},
timeout: options.timeout,
cwd: path.resolve(options.cwd)
});
},
event: function(msg){
switch (msg.event) {
case 'ErrorDeployInProgress':
console.error("Deploy already in progress. Press Ctrl+C to abort.");
break;
case 'Shutdown':
console.error("Bootup never succeeded. Check stderr.log and usage of 'online' and 'shutdown' events.");
process.exit(1);
break;
case 'DeployFailed':
console.log("Deploy failed. Check stderr.log and usage of 'online' and 'shutdown' events.");
process.exit(1);
break;
case 'Ready':
console.error("done");
process.exit(0);
break;
default:
printDaemonMsg(msg);
}
}
});
function setAbortKeyboardHook(){
process.once('SIGINT', handleSigInt);
}
function handleSigInt(){
console.error("aborting deploy");
json_socket.send(socket, {
action: 'NaughtDeployAbort'
});
}
}
function deployAbort(ipcFile){
var socket = getDaemonMessages(ipcFile, {
ready: function(){
json_socket.send(socket, {
action: 'NaughtDeployAbort'
});
},
event: function(msg){
switch (msg.event) {
case 'ErrorNoDeployInProgress':
console.error("no deploy in progress");
process.exit(1);
break;
case 'Ready':
console.error("deploy aborted");
process.exit(0);
break;
default:
printDaemonMsg(msg);
}
}
});
}
function displayStatus(ipcFile){
var socket = getDaemonMessages(ipcFile, {
ready: function(){
json_socket.send(socket, {
action: 'NaughtStatus'
});
},
event: function(msg){
if (msg.event === 'Status') {
process.stdout.write(statusMsg(msg));
socket.end();
} else {
printDaemonMsg(msg);
}
}
});
}
function extendedWorkerCount(workerCount){
if (workerCount === 'auto'){
return os.cpus().length;
} else {
return parseInt(workerCount, 10);
}
}