Jump To …

lservicemanager.js

/*
*
* Copyright (C) 2011, The Locker Project
* All rights reserved.
*
* Please see the LICENSE file for more information.
*
*/

var fs = require("fs");
var path = require("path");
var lconfig = require("lconfig");
var crypto = require("crypto");
var util = require("util");
var spawn = require('child_process').spawn;
var levents = require('levents');
var wrench = require('wrench');
var lutil = require(__dirname + "/lutil");
var logger;
var async = require('async');
var dispatcher = require('./instrument.js').StatsdDispatcher;

var stats;
var offline = lconfig.offline && lconfig.stats.indexOf('localhost')<0;

var serviceMap = { }; // All of the immediately addressable services in the system

var shuttingDown = null;
var syncletManager, registry;
var lockerPortNext = parseInt("1" + lconfig.lockerPort, 10);

/**
* Scans the Me directory for instaled services
*/
exports.init = function (sman, reg, callback) {
    logger = require('logger');
    logger.info('lservicemanager lockerPortNext = ' + lockerPortNext);

    if (!offline)
		stats = new dispatcher(lconfig.stats);

    syncletManager = sman;
    registry = reg;
    var dirs = fs.readdirSync(lconfig.me);
    for (var i = 0; i < dirs.length; i++) {
        if (dirs[i] == "diary") continue;
        var dir =  lconfig.me + '/' + dirs[i];
        try {
            if (!fs.statSync(dir).isDirectory()) continue;
            if (!path.existsSync(path.join(dir, 'me.json'))) continue;
            var js = serviceMap[dirs[i]] = JSON.parse(fs.readFileSync(path.join(dir, 'me.json'), 'utf8'));
            js.id = dirs[i]; // ensure symmetry
            cleanLoad(js);
            logger.info("Mapped /Me/" + js.id);
        } catch (E) {
            logger.error("Me/"+dirs[i]+" failed to load as a service (" +E+ ")");
        }
    }

make sure default collections, ui, and apps are all installed!

    var installs = [];
    if (lconfig.ui) {
        installs.push(lconfig.ui);
        if (lconfig.ui.indexOf(':') != -1) lconfig.ui = lconfig.ui.substr(0,lconfig.ui.indexOf(':')); // only use name hereafter
    }
    if (lconfig.apps) lconfig.apps.forEach(function(app){ installs.push(app); });
    if (lconfig.collections) lconfig.collections.forEach(function(coll){ installs.push(coll); });
    async.forEachSeries(installs, function(id, cb){
        var arg = {};

allow a configurable name:path/to/it value for local installs This will currently directly upsert and skip the install

        if (id.indexOf(':') != -1) {
            exports.mapUpsert(path.join(id.substr(id.indexOf(':')+1), "package.json"));
            return cb();
        } else {
            arg.name = id;
        }
        if (serviceMap[id]) return cb();
        registry.install(arg, cb);
    }, callback);
};

return whole map or just one service from it

exports.map = function(id) {
    if (id) return serviceMap[id];
    return serviceMap;
};

a way to signal that a specific service's data has changed and should be saved back to it's me.json

exports.mapDirty = function(id) {
    if (!serviceMap[id]) return;

this could use a timer to just save once after a few second delay, if something is saving a lot quickly

    var dir = path.join(lconfig.lockerDir, lconfig.me, id);
    if (!path.existsSync(dir)) fs.mkdirSync(dir, '0755');
    lutil.atomicWriteFileSync(path.join(dir, 'me.json'), JSON.stringify(serviceMap[id], null, 4));
};

/**
* Returns an array of the services that provide the specified types
*/
exports.providers = function(types) {
    var services = [];
    for(var svcId in serviceMap) {
        if (!serviceMap.hasOwnProperty(svcId))  continue;
        var service = serviceMap[svcId];
        if (!service.hasOwnProperty("provides")) continue;
        if (service.provides.some(function(svcType, index, actualArray) {
            for (var i = 0; i < types.length; i++) {
                var currentType = types[i];
                var currentTypeSlashIndex = currentType.indexOf("/");
                if (currentTypeSlashIndex < 0) {

This is a primary only comparison

                    var svcTypeSlashIndex = svcType.indexOf("/");
                    if (svcTypeSlashIndex < 0 && currentType == svcType) return true;
                    if (currentType == svcType.substring(0, svcTypeSlashIndex)) return true;
                    continue;
                }

Full comparison

                if (currentType == svcType) return true;
            }
            return false;
        })) {
            services.push(service);
        }
    }
    return services;
};

lutil.extend isn't smart enough to figure out how to handle synclets

function syncletMerge(alive, dead) {

we have to intelligently merge synclets array!

    if (dead.synclets) {
        if (!alive.synclets) alive.synclets = [];
        var ndx = {};
        for (var i = 0; i < alive.synclets.length; i++) ndx[alive.synclets[i].name] = alive.synclets[i];
        for (var i = 0; i < dead.synclets.length; i++) {
            var s = dead.synclets[i];
            if (ndx[s.name]) Object.keys(s).forEach(function (key) { ndx[s.name][key] = s[key]; });
            if (!ndx[s.name]) alive.synclets.push(s);
        }
    }

everything else dumb copy

    Object.keys(dead).forEach(function (key) { if (key != "synclets") alive[key] = dead[key]; });
}

update or install this file into the map

exports.mapUpsert = function (file) {
    var js;

ensure file is always relative to lockerdir

    file = lutil.relative(lconfig.lockerDir, file);
    logger.verbose("upsert request for "+file);
    try {
        js = JSON.parse(fs.readFileSync(file, 'utf8'));
        if (!js || !js.repository) throw new Error("missing or invalid data");

flatten into one, repository primary

        var repo = js.repository;
        delete js.repository;
        lutil.extend(true, js, repo);
        lutil.parseAuthor(js);
        if (!js.handle) throw new Error("no handle");
        js.handle = js.handle.toLowerCase(); // sanity

synclets are in their own file, extend them in too

        var sync = path.join(lconfig.lockerDir, path.dirname(file),"synclets.json");
        if (path.existsSync(sync)) {
            syncletMerge(js, JSON.parse(fs.readFileSync(sync, 'utf8')));
        }
    } catch (E) {
        logger.error("failed to upsert "+file+" due to "+E);
        return false;
    }

    js.upserted = Date.now();
    js.manifest = file;
    js.srcdir = path.dirname(file);
    js.id = js.provider = js.handle; // kinda legacy where they could differ

if it exists already, merge it in and save it

    if (serviceMap[js.handle]) {
        logger.verbose("updating "+js.handle);
        syncletMerge(serviceMap[js.handle], js);
        exports.mapReload(js.handle);

if it's running and updated, signal it to shutdown so new code/config is run at next request

        if (js.pid) try {
            logger.info("restarting " + js.handle + " running at pid " + js.pid);
            process.kill(js.pid, "SIGTERM");
        } catch(e) {}

worth detecting if it changed here first?

        levents.fireEvent('service://me/#'+js.handle, 'update', serviceMap[js.handle]);
        return serviceMap[js.handle];
    }

creating from scratch

    logger.verbose("creating "+js.handle);
    serviceMap[js.handle] = js;
    js.installed = Date.now();
    cleanLoad(js);
    levents.fireEvent('service://me/#'+js.handle, 'new', js);
    return js;
};

clean up any me json before loading into map

function cleanLoad(js) {
    delete js.pid; // not running
    delete js.starting; // or starting
    js.uri = lconfig.lockerBase+"/Me/"+js.id+"/";
    js.externalUri = lconfig.externalBase+"/Me/"+js.id+"/";
    lutil.parseAuthor(js);
    if (!js.version) js.version = 1;
    js.loaded = Date.now();
    exports.mapReload(js.id);
}

make sure this service's necessaries are loaded into the rest of the system

exports.mapReload = function(id) {
    var js = serviceMap[id];
    if (!js) return;

load any events

    if (js.events) {
        for (var i = 0; i < js.events.length; i++) {
            var ev = js.events[i];
            var batching = (ev.length > 2 && ev[2] === true) ? true : false;
            levents.addListener(ev[0], js.id, ev[1], batching);
        }
    }

start em up if they're ready

    if (js.synclets && js.auth && js.authed) {
        for (var j = 0; j < js.synclets.length; j++) {
            syncletManager.scheduleRun(js, js.synclets[j]);
        }
    }
    exports.mapDirty(js.id);
};

! Spawn a service instance

/**
* \param svc The service description to start
* \param callback Completion callback
*
* The service will be spanwed as described in its configuration file.  The service can
* read its environment description from stdin which will consist of one JSON object.  The
* object will have a mandatory port and workingDirectory field, but the rest is optional.
* \code
*   {
*     port:18044,
*     workingDirectory:"/some/path/"
*   }
* \endcode
* Once the service has completed its startup it will write out to stdout a single JSON object
* with the used port and any other environment information.  The port must be the actual
* port the service is listening on.
* \code
*   {
*     port:18044
*   }
* \encode
*/
exports.spawn = function(serviceId, callback) {
    if (!callback) callback = function () {};
    var svc = serviceMap[serviceId];

console.log("spawning "+svc);

    if (!svc) {
        logger.error("Attempting to spawn an unknown service " + serviceId);
        return callback();
    }
    if (!svc.run) {
        logger.error("No run information for " + serviceId);
        return callback();
    }

Already running

    if (svc.pid) return callback();

Queue up callbacks if we are already trying to start this service

    if (svc.hasOwnProperty("starting")) {
        logger.info(svc.id + " is still spawning, adding callback to queue.");
        svc.starting.push(callback);
        return;
    } else {
        svc.starting = [callback];
    }

    run = svc.run.split(" "); // node foo.js

    svc.port = ++lockerPortNext;
    logger.info('spawning into: ' + path.join(lconfig.lockerDir, lconfig.me, svc.id));
    var processInformation = getProcessInformation(svc);

    var env = process.env;
    env["NODE_PATH"] = path.join(lconfig.lockerDir, 'Common', 'node') + ":" + path.join(lconfig.lockerDir, "node_modules");
    env["PATH"] += ":" + processInformation.sourceDirectory;
    env["LOCKER_ROOT"] = lconfig.lockerDir;

    var tstart = Date.now();
    var command = run[0];
    var args = run.slice(1);

console.log("Spawning command '" + command + "'' with args " + JSON.stringify(args) + ", cwd " + processInformation.sourceDirectory + " and processInfo " + JSON.stringify(processInformation));

    logger.verbose("Spawning command '" + command + "'' with args " + JSON.stringify(args) + ", cwd " + processInformation.sourceDirectory + " and processInfo " + JSON.stringify(processInformation));
    var app = spawn(command, args, {cwd: processInformation.sourceDirectory, env:process.env});

    if (!offline)
    {
	  stats.increment('service.' + serviceId + '.start');
      stats.increment('service.' + serviceId + '.running');
    }

    app.stderr.on('data', function (data) {
        process.stderr.write('[' + svc.id + '] ' + data.toString());
    });

    var dbuff = "";
    app.stdout.setEncoding("utf8");
    app.stdout.on('data',function (data) {
        if (svc.hasOwnProperty("pid")) {

We're already running so just log it for them process.stdout.setEncoding('utf-8');

            process.stdout.write('[' + svc.id + '] ' + data.toString());
        } else {

Process the startup json info

            dbuff += data;

console.log("data "+data); //console.log("JSON.parse(dbuff) "+JSON.parse(dbuff));

            var returnedProcessInformation;
            try {
                var nl = dbuff.indexOf("\n");
                if (nl > 0) dbuff = dbuff.substr(0,nl); // cut off any extra stuff, if any... TODO: we should have better newline parsing
                returnedProcessInformation = JSON.parse(dbuff);

            } catch(error) {

json hasn't all been written yet, come back, should use newline terminated!

                if (dbuff.substr(0,1) == '{') {
                    return;
                }
                logger.error("The process did not return valid startup information. "+error+" on "+dbuff);
                app.kill();
            }
            try {

if they tell us a port, use that

                if (returnedProcessInformation.port) svc.port = returnedProcessInformation.port;
                svc.uriLocal = "http://localhost:"+svc.port+"/";
                logger.info("Set local uri to " + svc.uriLocal + " for " + svc.id);

save out all updated meta fields fs.writeFileSync(lconfig.lockerDir + "/" + lconfig.me + "/" + svc.id + '/me.json',JSON.stringify(svc, null, 4)); Set the pid after the write because it's transient to this locker instance only I'm confused why we have to use startingPid and app.pid is invalid here

                svc.pid = svc.startingPid;
                delete svc.startingPid;
                logger.info(svc.id + " started at pid " + svc.pid + ", running startup callbacks, timing "+(Date.now() - tstart));
                if (svc.starting) svc.starting.forEach(function(cb) {
                    cb.call();

See if it ended whilst running the callbacks

                    if (!svc.hasOwnProperty("pid") && svc.starting.length > 0) {

We'll try again in a sec

                        setTimeout(function() {
                            exports.spawn(svc.id);
                        }, 10);
                        return;
                    }
                });
                delete svc.starting;
                svc.started = Date.now();
            } catch(error) {
                logger.error("The process "+svc.name +" did not return valid startup information. "+error);
                app.kill();
            }
        }
    });

    app.on('exit', function (code,signal) {
        logger.info(svc.id + " exited with status " + code + ", signal " + signal);
	    if (!offline) {
          stats.increment('service.' + svc.id + '.stop');
          stats.decrement('service.' + svc.id + '.running');
        }
        var id = svc.id;

remove transient fields

        delete svc.pid;
        delete svc.port;
        delete svc.uriLocal;
        delete svc.starting;
        delete svc.started;
        svc.ended = Date.now();
        checkForShutdown();
    });

    logger.verbose("sending "+svc.id+" startup info of "+JSON.stringify(processInformation));

    app.stdin.on('error',function(err) {
        logger.error("STDIN error:" + util.inspect(err));
    });
    app.stdin.write(JSON.stringify(processInformation)+"\n"); // Send them the process information

We track this here because app.pid doesn't seem to work inside the next context

    svc.startingPid = app.pid;
    svc.last = Date.now();
    setTimeout(function() { quiesce(svc); }, lconfig.quiesce);
};

function getProcessInformation(svc) {
  var processInformation = {
    port: svc.port, // This is just a suggested port
    sourceDirectory: ((svc.srcdir.charAt(0) == '/') ? svc.srcdir : path.join(lconfig.lockerDir, svc.srcdir)),
    workingDirectory: path.join(lconfig.lockerDir, lconfig.me, svc.id), // A path into the me directory
    lockerUrl: lconfig.lockerBase,
    externalBase: lconfig.externalBase + '/Me/' + svc.id + '/'
  };
  if (svc.mongoCollections) {
    processInformation.mongo = {
      host: lconfig.mongo.host,
      port: lconfig.mongo.port
    };
    processInformation.mongo.collections = svc.mongoCollections;
  }
  return processInformation;
}

function quiesce(svc) {
    if (!svc) return;
    if (svc.starting || Date.now() - svc.last < (lconfig.quiesce / 2)){
        logger.info("delaying quiesce for "+svc.id);
        return setTimeout(function() { quiesce(svc); }, lconfig.quiesce);
    }
    if (!svc.pid) return logger.warn("trying to quiesce "+svc.id+" but missing pid");
    try {
        logger.info("quiesce idle service " + svc.id + " at pid " + svc.pid);
        process.kill(svc.pid, "SIGTERM");
    } catch(e) {
        logger.error("got error while quiescing: "+e);
    }
}

/**
* Shutdown all running services
*
* \param cb Callback to call when the shutdown is complete
*/
exports.shutdown = function(cb) {
    shuttingDown = cb;
    for(var mapEntry in serviceMap) {
        var svc = serviceMap[mapEntry];
        if (svc.pid) {
            try {
                logger.info("Signalling " + svc.id + " to shut down (pid " + svc.pid + ")");
                process.kill(svc.pid, "SIGINT");
            } catch(e) {
                logger.error("got error while shutting down: "+e);
            }
        }
    }
    checkForShutdown();
};


/* nothing uses this and it seems dangerously destructive
exports.uninstall = function(serviceId, callback) {
    var svc = serviceMap.installed[serviceId];
    var lmongo = require('lmongo');
    lmongo.init(svc.id, svc.mongoCollections, function(mongo, colls) {
        var keys = Object.getOwnPropertyNames(colls);
        (function deleteCollection (keys, callback) {
            if (keys.length > 0) {
                key = keys.splice(0, 1);
                coll = colls[key];
                coll.drop(function() {deleteCollection(keys, callback);});
            } else {
                callback();
            }
        })(keys, function() {
            svc.uninstalled = true;
            if (svc.pid) {
                process.kill(svc.pid, "SIGINT");
            }
            wrench.rmdirSyncRecursive(lconfig.me + "/" + serviceId);
            delete serviceMap.installed[serviceId];
            callback();
        });
    });
}
*/

/**
* Return whether the service is running
*/
exports.isRunning = function(serviceId) {
    return serviceMap[serviceId] && serviceMap[serviceId].pid;
};

function checkForShutdown() {
    if (!shuttingDown) return;
    for(var mapEntry in serviceMap) {
        var svc = serviceMap[mapEntry];
        if (svc.pid)  {
            logger.info("Waiting for "+svc.id+" to exit.");
            return;
        }
    }
    shuttingDown();
    shuttingDown = null;
}

exports.getCollectionApis = function() {
  var collectionApis = {};
  for (var i in serviceMap) {
    if (serviceMap[i].type === 'collection') {
      var modulePath = path.join(lconfig.lockerDir, serviceMap[i].srcdir, 'api.js');
      if (path.existsSync(modulePath)) {
        collectionApis[i] = {
          api: require(modulePath),
          lockerInfo: getProcessInformation(serviceMap[i])
        };
      }
    }
  }
  return collectionApis;
};