RackHD/on-core

View on GitHub
lib/common/child-process.js

Summary

Maintainability
D
2 days
Test Coverage
// Copyright 2015, EMC, Inc.

'use strict';

module.exports = ChildProcessFactory;

ChildProcessFactory.$provide = 'ChildProcess';
ChildProcessFactory.$inject = [
    'child_process',
    'Errors',
    'Logger',
    'Assert',
    'Util',
    'Promise',
    'Constants',
    '_',
    'fs',
    'path'
];

/**
 * childProcessFactory returns a ChildProcess constructor
 * @param {Q} Q Promise Library
 * @param {Logger} Logger module
 * @param {Object} assert assertion module
 * @param {Object} _ lodash module
 * @private
 */
function ChildProcessFactory (
    nodeChildProcess,
    Errors,
    Logger,
    assert,
    util,
    Promise,
    Constants,
    _,
    fs,
    path
) {
    var logger = Logger.initialize(ChildProcessFactory);

    /**
     * ChildProcess provides a promise based mechanism to run shell commands
     * in a fairly secure manner.
     * @constructor
     */
    function ChildProcess (command, args, env, code, maxBuffer) {
        var self = this;

        assert.string(command);
        assert.optionalArrayOfNumber(code);

        self.command = command;
        self.file = self._parseCommandPath(self.command);
        self.args = args;
        self.environment = env || {};
        self.exitCode = code || [0];
        self.maxBuffer = maxBuffer || Constants.ChildProcess.MaxBuffer;

        if (!self.file) {
            throw new Error('Unable to locate command file (' + self.command +').');
        }
        if (!_.isEmpty(self.args)) {
            try {
                assert.arrayOfString(self.args, 'ChildProcess command arguments');
            } catch (e) {
                throw new Error('args must be an array of strings');
            }
        }

        self.hasBeenKilled = false;
        self.hasBeenCancelled = false;
        self.spawnInstance = undefined;
    }

    ChildProcess.prototype.createOwnDeferred = function() {
        var self = this;
        self._deferred = new Promise(function(resolve, reject) {
            self._resolve = resolve;
            self._reject = reject;
        });
    };

    ChildProcess.prototype.resolve = function(result) {
        if (!this._deferred.isPending()) {
            logger.error("ChildProcess promise has already been resolved");
            return;
        } else {
            this._resolve(result);
        }
    };

    ChildProcess.prototype.reject = function(error) {
        if (!this._deferred.isPending()) {
            logger.error("ChildProcess promise has already been rejected");
            return;
        } else {
            this._reject(error);
        }
    };

    ChildProcess.prototype.killSafe = function (signal) {
        if (!this.hasBeenKilled && this.spawnInstance && _.isFunction(this.spawnInstance.kill)) {
            this.spawnInstance.kill(signal);
        }
        this.hasBeenCancelled = true;
    };

    /**
     * Runs the given command.
     * @param  {String} command File to run, with path or without.
     * @param  {String[]} args    Arguments to the file.
     * @param  {Object} env     Optional environment variables to provide.
     * @param  {Integer} code   Desired exit code, defaults to 0.
     * @return {Promise}        A promise fulfilled with the stdout, stderr of
     * a successful command.
     */
    ChildProcess.prototype._run = function () {
        var self = this;

        if (self.hasBeenCancelled) {
            self.reject(new Errors.JobKilledError("ChildProcess job has been cancelled", {
                command: self.command,
                argv: self.args
            }));
            return;
        }

        self.hasBeenKilled = false;

        var options = {
            env: self.environment,
            maxBuffer: self.maxBuffer
        };

        self.spawnInstance = nodeChildProcess.execFile(
                self.file, self.args, options, function (error, stdout, stderr) {

            if (error && self.exitCode.indexOf(error.code) === -1) {
                self.hasBeenKilled = true;
                logger.error('Error Running ChildProcess.', {
                    file: self.file,
                    argv: self.args,
                    stdout: stdout,
                    stderr: stderr,
                    error: error
                });
                self.reject(error);
            } else {
                self.hasBeenKilled = true;
                self.resolve({
                    stdout: stdout,
                    stderr: stderr
                });
            }
        })
        .on('close', function(code, signal) {
            if (signal) {
                logger.warning("Child process received closing signal:", {
                    signal: signal,
                    argv: self.args
                });
            }
            self.hasBeenKilled = true;
        })
        .on('error', function(code, signal) {
            logger.error("Child process received closing signal but has " +
                "already been closed!!!", {
                signal: signal,
                argv: self.args
            });
        });
    };

    ChildProcess.prototype._runWithRetries = function(retryCount, delay, retries) {
        var self = this;

        // Max out exponential backoff to 60 seconds.
        delay = delay > 60000 ? 60000 : delay;

        if (!self._deferred) {
            self.createOwnDeferred();
        }

        self._run();

        return self._deferred.catch(function(e) {
            if (e instanceof Errors.JobKilledError) {
                throw e;
            } else if (retryCount < retries) {
                logger.debug("Retrying ChildProcess command.", {
                    file: self.file,
                    args: self.args
                });
                // Create new deferred promise in here instead of above, in case we receive an
                // external cancellation during the below delay, so we don't
                // try to reject using the previous deferred object.
                self.createOwnDeferred();
                return Promise.delay(delay)
                .then(function() {
                    return self._runWithRetries(retryCount + 1, delay * 2, retries);
                });
            } else {
                throw e;
            }
        });
    };

    ChildProcess.prototype.run = function(options) {
        options = options || {};
        options.retries = options.retries || 0;
        if (options.delay !== 0) {
            options.delay = options.delay || 500;
        }

        return this._runWithRetries(0, options.delay, options.retries);
    };

    /**
     * Internal method to identify the path to the command file.  It's essentially
     * unix which in JavaScript.
     * @private
     */
    ChildProcess.prototype._parseCommandPath = function _parseCommandPath (command) {
        var self = this;

        if (self._fileExists(command)) {
            return command;
        } else {
            var found = _.some(self._getPaths(), function (current) {
                var target = path.resolve(current + '/' + command);

                if (self._fileExists(target)) {
                    command = target;

                    return true;
                }
            });

            return found ? command : null;
        }
    };

    /**
     * Internal method to verify a file exists and is not a directory.
     * @private
     */
    ChildProcess.prototype._fileExists = function _fileExists (file) {
        return fs.existsSync(file) && !fs.statSync(file).isDirectory();
    };

    /**
     * Internal method to get an array of directories in the users path.
     * @private
     */
    ChildProcess.prototype._getPaths = function _getPaths () {
        var path = process.env.path || process.env.Path || process.env.PATH;
        return path.split(':');
    };

    return ChildProcess;
}