component-director.js

"use strict";

const { spawn } = require("child_process");
const Spinner = require("cli-spinner").Spinner;
const path = require("path");
const fs = require("fs");
const stripJsonComments = require("strip-json-comments");
const mkdirp = require("mkdirp");
const chownr = require("chownr");
const ComponentManager = require("./component-manager");
var log;

var componentDirectorSingleton;

/**
 * The ComponentDirector manages the ComponentManager. It controls the lifecycle
 * of components via the `scm-config.json`. It is primarily used through the commandline
 * via the `scm` command.
 */
class ComponentDirector {
    constructor(opts) {
        if (componentDirectorSingleton) return componentDirectorSingleton;

        // I'm the singleton
        componentDirectorSingleton = this; // eslint-disable-line consistent-this

        // create a component manager
        this.cm = new ComponentManager();

        // load configuration
        opts = opts || {};
        this.origConfig = opts;
        this.config = {};
        this.confList = [opts];
    }

    /**
     * Allocates a new {@link ComponentDirector} and starts all the components.
     * @param  {Object} opts Options for the component director.
     * @return {Promise.<ComponentDirector>}      A Promise that resolves to the new ComponentDirector when complete, or rejects on error.
     */
    static async start(opts) {
        try {
            console.log("starting...");
            var cd = new ComponentDirector(opts);

            await cd.processConfig();
            // `npm install` all the packages specified by the components
            await cd.installComponents(cd.componentList);
            // require each component and add each to the ComponentManager
            await cd.initComponents(cd.componentList);

            console.log("start done");

            // chown data dir
            if (cd.cm.dataDir &&
                cd.setgid !== undefined &&
                cd.setuid !== undefined) {
                chownr.sync(cd.cm.dataDir, cd.setuid, cd.setgid);
            }

            // change group id (always do this before uid ;)
            if (cd.setgid !== undefined) {
                process.setgid(cd.setgid);
            }

            // change user id
            if (cd.setuid !== undefined) {
                process.setuid(cd.setuid);
            }

            // grab a logger instance for future use
            var logger = cd.cm.get("logger");
            if (logger === undefined) {
                throw new Error("logger component not found");
            }
            log = logger.create("ComponentDirector");
            cd.log = log;

            log.debug("Started ComponentDirector.");

            return cd;
        } catch (err) {
            console.log("ERROR:", err);
            throw err;
        }
    }

    /**
     * A wrapper for {@link ComponentDirector#start}
     * @param  {Object} opts Options
     * @return {Promise.<ComponentDirector>}      A Promise that resolves to ComponentDirector on success, or rejects with an Error on failure.
     */
    start(opts) {
        return ComponentDirector.start(opts);
    }

    static stop() {
        if (this.cm) this.cm.shutdown();
        componentDirectorSingleton = null;
    }

    static readConfig(filename) {
        try {
            var json = fs.readFileSync(filename, "utf8");
            return JSON.parse(stripJsonComments(json));
        } catch (e) {
            console.log("WARNING: couldn't load config file:", filename);
        }
    }

    readConfig(filename) {
        return ComponentDirector.readConfig(filename);
    }

    loadConfig(conf) {
        if (typeof conf === "string") {
            conf = ComponentDirector.readConfig(conf);
            if (!conf) return;
        }

        var confList = this.confList;
        confList.push(conf);

        // load any included files
        if (Array.isArray(conf.includeFiles)) {
            conf.includeFiles.forEach((file) => {
                let c = ComponentDirector.readConfig(file);
                if (c) {
                    if (!c.configDir) c.configDir = path.dirname(path.resolve(file));
                    confList = confList.concat(this.loadConfig(c));
                }
            });
        }
    }

    processConfig() {
        return new Promise((resolve) => {
            // create a list of all the configuration files
            var confList = this.confList;
            var componentList = [];

            // aggregate a complete list of components from the config files
            for (let config of confList) {
                // set main configDir & dataDir if they're not already set
                if (config.configDir && !this.config.configDir) this.config.configDir = config.configDir;
                if (config.dataDir) this.config.dataDir = config.dataDir;

                // save gid / uid
                if (config.setuid) {
                    if (typeof config.setuid !== "number") {
                        throw new Error("expected setuid to be number");
                    }
                    this.setuid = config.setuid;
                }
                if (config.setgid) {
                    if (typeof config.setgid !== "number") {
                        throw new Error("expected setgid to be number");
                    }
                    this.setgid = config.setgid;
                }

                // save component list
                if (Array.isArray(config.components)) {
                    componentList = componentList.concat(config.components);
                }
            }

            // if dataDir was never set, use the configDir
            if (!this.config.configDir) this.config.configDir = ".";
            if (!this.config.dataDir) this.config.dataDir = path.join(this.config.configDir, "data");
            // solidify the file paths
            this.config.configDir = fs.realpathSync(path.resolve(this.config.configDir));
            mkdirp.sync(this.config.dataDir);
            this.config.dataDir = fs.realpathSync(path.resolve(this.config.dataDir));
            this.cm.setDataDir(this.config.dataDir);

            componentList.forEach((component) => this.cleanComponent(component));

            this.componentList = componentList;
            resolve(confList);
        });
    }

    installComponents(componentList) {
        console.log("installing...");
        return new Promise((resolve, reject) => {
            // reduce component list to list of unique package names
            var packageList = componentList.map((c) => c.package);
            packageList = packageList.filter((item, pos) => packageList.indexOf(item) === pos);
            // add all the package paths to the components
            componentList.forEach((component) => {
                var versionPos = component.packageName.indexOf("@");
                let packageName = (versionPos === -1) ? component.packageName : component.packageName.substring(0, versionPos);
                component.packagePath = path.join(this.config.dataDir, "node_modules", packageName);
            });
            if (packageList.length === 0) return resolve();

            // spawn npm to install components at the desired prefix
            var npmArgs = [];
            npmArgs.push("--prefix", this.config.dataDir);
            npmArgs.push("--color", "false");
            npmArgs.push("install");

            // if we're going to install as root, include this arg...
            if (process.getuid() === 0 && !this.setuid) npmArgs.push("--unsafe-perm");
            npmArgs = npmArgs.concat(packageList);

            console.log("Updating components:\n\t" + packageList.join("\n\t") + "\n");

            // console.log("npm args", npmArgs);
            // make sure we have the right permissions...
            if (this.setuid && this.setgid) chownr.sync(this.cm.dataDir, this.setuid, this.setgid);
            // do the install
            var npmPs = spawn("npm", npmArgs, {
                uid: this.setuid ? this.setuid : process.getuid(),
                gid: this.setgid ? this.setgid : process.getgid()
            });

            // spin while we're waiting...
            var spinner = new Spinner("Updating components...");
            spinner.setSpinnerString(0);
            spinner.start();

            npmPs.stdout.on("data", (data) => {
                // console.log(data.toString());
            });

            npmPs.stderr.on("data", (data) => {
                console.log(data.toString());
            });

            npmPs.on("close", (code) => {
                spinner.stop(true);
                // console.log("npm finished with code:", code);
                if (code === 0) {
                    return resolve(packageList);
                }
                console.log("npm installation failed. Error code", code);
                return reject(code);
            });
        });
    }

    initComponents(componentList) {
        console.log("loading...");
        return promiseSequence(componentList, (component) => {
            var obj = require(component.packagePath); // eslint-disable-line global-require
            if (typeof obj === "function") {
                component.LoadedClass = obj;
                component.loaded = new component.LoadedClass(this.cm);
            } else {
                component.loaded = obj;
            }

            this.cm.register(component.name, component.type, component.loaded);
        })
            .then(() => {
                // pre-configure components
                iterateComponentListConfig.call(this, componentList, "pre-config", (key, val, component) => {
                    console.log("pre-config:", component.name, key, val);
                    this.cm.config(component.name, key, val);
                });
            })
            .then(() => this.cm.init())
            .then(() => {
                // post-configure components
                iterateComponentListConfig.call(this, componentList, "post-config", (key, val, component) => {
                    console.log("post-config:", component.name, key, val);
                    this.cm.config(component.name, key, val);
                });
            });
    }

    cleanComponent(component) {
        if (typeof component.name !== "string") {
            throw new TypeError("component missing name:\n" + component);
        }

        if (typeof component.name !== "string") {
            throw new TypeError(`component "${component.name}" missing type:\n` + component);
        }

        if (typeof component.type !== "string") {
            throw new TypeError(`component "${component.name}" missing type:\n` + component);
        }

        if (component["pre-config"] && !Array.isArray(component["pre-config"])) {
            throw new TypeError(`component "${component.name}" has malformed "pre-config":\n` + component);
        }

        if (component["pre-config"]) component["pre-config"].forEach((config) => {
            if (typeof config !== "object") {
                throw new TypeError(`component "${component.name}" has malformed "pre-config" entry:\n` + config);
            }
        });

        if (component["post-config"] && !Array.isArray(component["post-config"])) {
            throw new TypeError(`component "${component.name}" has malformed "post-config":\n` + component);
        }

        if (component["post-config"]) component["post-config"].forEach((config) => {
            if (typeof config !== "object") {
                throw new TypeError(`component "${component.name}" has malformed "pre-config" entry:\n` + config);
            }
        });

        component.packageName = component.packageName || this.componentResolvePackageName(component);
    }

    componentResolvePackageName(component) {
        var packageName = component.package;
        // console.log ("package name before:", packageName);

        // TODO: should probably unpack the .tgz and parse the package.json rather than assuming that the package is named right
        // if package is .tgz url
        if (packageName.match(/https?:\/\/.*\.tgz$/)) {
            packageName = path.basename(packageName.split("://")[1], ".tgz");
        } else if (packageName.match(/\.tgz$/)) {
            // if package is .tgz
            packageName = path.basename(packageName, ".tgz");
        } else try { // if package is folder
            if (packageName === "") packageName = "."; // if package is empty string use local dir
            var basepath = component.configDir || process.cwd(); // TODO XXX: should be the path where the config file was loaded from
            var packageJson = path.resolve(basepath, packageName, "package.json");
            packageName = require(packageJson).name; // eslint-disable-line global-require
        } catch (e) {
            // console.log (e);
            }

        // console.log ("package name after:", packageName);
        return packageName;
    }
}

function iterateComponentListConfig(componentList, config, cb) {
    componentList.forEach((component) => {
        function iterateConfigObj(configObj) {
            Object.keys(configObj).forEach((key) => {
                cb(key, configObj[key], component, config);
            });
        }

        function iterateConfigArray(configArr) {
            configArr.forEach((configObj) => {
                iterateConfigObj(configObj);
            });
        }

        if (Array.isArray(component[config])) iterateConfigArray(component[config]);
        else if (typeof component[config] === "object") iterateConfigObj(component[config]);
    });
}

function promiseSequence(list, fn) {
    var pacc = Promise.resolve();
    for (let item of list) {
        pacc = pacc.then(() => fn(item));
    }
    return pacc;
}

module.exports = ComponentDirector;