nac
Version:
node app control and monitoring daemon based on config files (somewhat similar to procfiles)
464 lines (366 loc) • 12.2 kB
JavaScript
var db = require('../db');
var log = require('./logger');
var through = require('through');
var daemonConfig = require('./config');
var appconfOverride = require('../appconf-override');
var opts2args = require('../opts2args');
var duration = require('../duration');
var util = require('util');
var fs = require('fs');
var path = require('path');
var yaml = require('js-yaml');
var async = require('async');
var _ = require('lodash');
var EventEmitter = require('events').EventEmitter;
var app = module.exports = db.define({
name: 'apps',
columns: [
{ name: 'id', dataType: 'integer primary key' },
{ name: 'uid', dataType: 'integer' },
{ name: 'name', dataType: 'text' },
{ name: 'nacfile', dataType: 'text' },
{ name: 'active', dataType: 'integer' }
]
});
var spawn = require('child_process').spawn;
app.all = function (cb) {
app.select().all(function (err, apps) {
if (err) return cb(err);
if (apps) return async.map(apps, function (appinfo, cb) {
return app.construct(_.merge({boot: true}, appinfo), cb);
}, cb);
return cb(null, []);
});
};
app.construct = function (opts, cb) {
if (opts.name == 'all')
return cb(new Error('Name "all" is reserved'));
fs.readFile(opts.nacfile, function (err, data) {
try {
if (!err) {
var config = yaml.safeLoad(data.toString(), {
filename: opts.nacfile
});
opts.config = config;
} else if (opts.boot) {
opts.config = {};
}
var app = new App(opts);
var done = (function (err) {
if (err) return cb(err);
if (this.active)
this.start(function() {});
return cb(null, app);
}.bind(app));
if (!app.id) app.save(done);
else done();
} catch (e) {
cb(e);
}
});
};
util.inherits(App, EventEmitter);
function App(opts) {
EventEmitter.call(this);
this.id = opts.id;
this.uid = opts.uid;
this.name = opts.name;
this.active = opts.active;
this.started = false;
this.setConfig(opts.nacfile, opts.config);
}
App.prototype.setConfig = function (nacfile, config) {
this.nacfile = nacfile;
this.config = appconfOverride(daemonConfig.tags, config);
this.cwd = path.resolve(path.dirname(nacfile), config.cwd || '');
this.logger = log.construct(this.id);
// set exponential backoff defaults
var backoff = this.config.respawn = this.config.respawn || {};
backoff.min = backoff.min || 0.25;
backoff.max = backoff.max || 120;
this.respawntime = this.respawntime || backoff.min;
};
App.prototype.save = function (cb) {
var self = this;
var fields = {
uid: this.uid,
name: this.name,
nacfile: this.nacfile,
active: this.active
};
if (!this.id)
app.select(app.id).where({uid: self.uid, name: self.name})
.get(function (err, res) {
if (res)
return cb(new Error("App already exists"));
app.insert(fields).exec(function (err, res) {
if (err) return cb(err);
app.where({uid: self.uid, name: self.name})
.get(function (err, app) {
if (err) return cb && cb(err);
self.id = app.id;
self.logger.appId = self.id;
return cb && cb(null, self);
});
});
});
else app.where({id: this.id}).update(fields)
.exec(function (err, res) {
return cb && cb(err, self);
});
};
App.prototype._getEnv = function () {
return _.merge({}, process.env, this.config.env, {
NACFILE: this.nacfile,
NACDIR: this.cwd,
NACNAME: this.name
});
};
App.prototype._runProcess = function (done) {
this.started = Date.now();
var args = opts2args(this.config.args);
if (!this.config.command)
return setTimeout(function () {
this.active = false;
this.started = null;
return done && done(
new Error('unable to read configuration parameter "command" of ' + this.name));
}.bind(this))
var proc = this.process = spawn(
this.config.command, args, {
env: this._getEnv(),
cwd: this.cwd,
uid: this.uid,
gid: this.uid
});
proc.on('error', function(err) {
if (answerOk) {
clearTimeout(answerOk);
answerOk = null;
if (done) done(err);
}
this._onExit(err)
}.bind(this));
proc.on('exit', this._onExit.bind(this));
proc.stdout.on('error', function() {});
proc.stderr.on('error', function() {});
proc.stdout.pipe(this.logger.stream('stdout'));
proc.stderr.pipe(this.logger.stream('stderr'));
var self = this;
// Hack: the node.js api docs don't say when
// the error event is expected, if the spawn fails so we use a
// "reasonable" timeout of 100ms here
if (done) var answerOk = setTimeout(function() {
answerOk = null;
done(null);
}, 100);
return proc;
}
;
App.prototype.start = function (cb) {
var self = this;
if (this.started)
return cb(new Error("app " + this.name + " already started"));
if (this._respawner) {
clearTimeout(this._respawner);
this._respawner = null;
}
this.active = true;
this.started = Date.now();
this.emit('before-start');
this.save(function (err) {
if (err) {
this.logger.log('start', "application failed to save: "
+ err.message);
return cb(err);
}
this._runProcess(function (err) {
if (err) {
this.logger.log('start', "application failed to start: "
+ err.message);
cb(err, this);
} else {
this.logger.log('start', "application started");
this.emit('start');
cb(null, this);
}
}.bind(this));
}.bind(this));
};
App.prototype._onExit = function (code, signal) {
var lastStarted = this.started;
this.started = null;
if (this.active) {
var restarting = this.restarting;
// didnt die by signal or died while restarting.
this.restarting = false;
var backoff = this.config.respawn;
var timeout = Math.min(this.respawntime, backoff.max);
var realTimeout = timeout * 1000 - (Date.now() - lastStarted);
realTimeout = Math.max(realTimeout, 1); // min 1ms
if (restarting)
realTimeout = 1;
if (!restarting)
this.logger.log('respawn', 'process died with exit code ' + code
+ ', respawning in ' + timeout.toFixed(1) + 's');
this.emit('respawn', {
signal: signal,
code: code
});
this._respawner = setTimeout(this._runProcess.bind(this), realTimeout);
if (!restarting) {
this.respawntime = Math.min(this.respawntime * 2, backoff.max);
this._backoff();
}
} else {
this.logger.log('exit', 'process died with code ' + code
+ ' after receiving ' + signal);
this.emit('exit', {code: code, signal: signal});
}
};
App.prototype._backoff = function _backoff() {
var backoff = this.config.respawn;
if (this._backoffTimer)
clearTimeout(this._backoffTimer);
this._backoffTimer = setTimeout(function () {
this._backoffTimer = null;
this.respawntime = this.respawntime / 2;
if (this.respawntime <= backoff.min)
this.respawntime = backoff.min;
else
this._backoff();
}.bind(this), backoff.max * 1000);
};
App.prototype.stop = function stop(cb) {
this.logger.log('stop', 'received stop command');
this.active = false;
if (this._respawner) {
clearTimeout(this._respawner);
this._respawner = null;
}
this.save(function (err) {
if (err) return cb(err);
if (this.process)
this.process.kill();
cb(null, this);
}.bind(this));
};
App.prototype.restart = function restart(cb) {
if (!this.process)
this.start(cb)
else {
this.logger.log('restart', 'received restart command');
this.restarting = true;
this.process.kill();
cb(null, this);
}
};
App.prototype.kill = function kill(signal, cb) {
try {
this.process.kill(signal);
this.logger.log('kill', signal);
} catch (e) {
return cb(e);
}
return cb(null, this);
};
App.prototype.run = function run(script) {
var args = [].slice.call(arguments, 1);
var cb = args[args.length - 1];
args = opts2args.flatten(args.slice(0, args.length - 1))
if (!this.config.scripts || !this.config.scripts[script])
return cb && cb(new Error('Unknown script: ' + script));
var scriptpath = path.join(
path.dirname(this.nacfile),
this.config.scripts[script]);
var scriptwd = path.dirname(scriptpath);
this.logger.log("script", "--- running " + script + ' ' + args.join(' '));
var scriptp = spawn(scriptpath, args, {
env: this._getEnv(),
cwd: scriptwd,
uid: this.uid,
gid: this.uid
});
var output = through();
scriptp.stdout.pipe(output);
scriptp.stderr.pipe(output);
var scriptError = function scriptError(e) {
var msg = script + args.join(' ') + " > error " + e.message;
this.logger.log("script", msg);
}.bind(this);
scriptp.on('error', scriptError);
scriptp.stdout.on('error', scriptError);
scriptp.stderr.on('error', scriptError);
scriptp.on('exit', function (code) {
setImmediate(this.logger.log.bind(this.logger, "script",
"--- exited " + script + ' ' + args.join(' ')
+ "with error code " + code));
}.bind(this));
var clientStream = through();
clientStream.pause();
output.pipe(clientStream);
output.pipe(this.logger.stream('script'));
function dataend(datacb, endcb) {
clientStream.on('data', function (d) {
datacb(d.toString());
});
clientStream.on('end', endcb);
clientStream.resume();
}
return cb && cb(null, {
appname: this.name,
script: script,
file: this.config.scripts[script],
pipe: dataend
});
};
App.prototype.update = function (nacfile, cb) {
this.logger.log('update', nacfile);
if (!cb) {
cb = nacfile;
nacfile = this.nacfile;
}
fs.readFile(nacfile, function (err, data) {
var config = yaml.safeLoad(data.toString(), {
filename: nacfile
});
this.setConfig(nacfile, config);
cb(null, this);
}.bind(this));
};
App.prototype.uptime = function (cb) {
if (!this.started)
if (cb) return cb(null, '- ')
else return '- ';
var uptime = duration.stringify(Date.now() - this.started);
if (cb) cb(null, uptime);
else return uptime;
};
App.prototype.status = function (cb) {
var status = {
name: this.name,
nacfile: this.nacfile,
active: this.active,
uptime: this.uptime(),
pid: this.process ? this.process.pid : '-'
};
if (cb) cb(null, status);
return status;
};
App.prototype.logs = function (opt, cb) {
if (!cb) {
cb = opt;
opt = {};
}
this.logger.lines(opt, cb);
};
App.prototype.destroy = function (cb) {
this.stop(function (err) {
if (err) return cb(err);
app.where({id: this.id}).delete().exec(function (err) {
if (err) return cb(err);
this.destroyed = true;
cb(null, this);
}.bind(this));
}.bind(this));
};