UNPKG

storjshare-daemon

Version:

daemon + process manager for sharing space on the storj network

436 lines (369 loc) 10.9 kB
'use strict'; const async = require('async'); const fs = require('fs'); const {statSync, readFileSync} = require('fs'); const stripJsonComments = require('strip-json-comments'); const FsLogger = require('fslogger'); const JsonLogger = require('kad-logger-json'); const {fork} = require('child_process'); const utils = require('./utils'); const path = require('path'); const { cpus } = require('os'); const {homedir} = require('os'); /** Class representing a local RPC API's handlers */ class RPC { /** * Creates a environment to manage node processes * @param {Object} options * @param {Number} options.logVerbosity */ constructor(options={}) { this.jsonlogger = new JsonLogger(options.logVerbosity); this.shares = new Map(); } /** * Logs the message by pushing it out the stream * @param {String} message * @param {String} level */ _log(msg, level='info') { this.jsonlogger[level](msg); } /** * Handles IPC messages from a running node * @private */ _processShareIpc(share, msg) { // NB: We receive a complete state object from nodes when an event // NB: occurs that updates the state object share.meta.farmerState = msg; } /** * Reads the config file and returns the parsed version * @private */ _readConfig(configPath) { let config = null; try { statSync(configPath); } catch (err) { throw new Error(`failed to read config at ${configPath}`); } try { config = JSON.parse(stripJsonComments( readFileSync(configPath).toString() )); } catch (err) { throw new Error(`failed to parse config at ${configPath}`); } config = utils.repairConfig(config); try { utils.validate(config); } catch (err) { throw new Error(err.message.toLowerCase()); } return config; } /** * Starts a share process with the given configuration * @param {String} configPath * @param {Boolean} unsafeFlag * @param {RPC~startCallback} * @see https://storj.github.io/core/FarmerInterface.html */ start(configPath, callback, unsafeFlag=false) { /*jshint maxcomplexity:7 */ let config = null; if (this.running >= cpus().length && !unsafeFlag) { return callback(new Error('insufficient system resources available')); } try { config = this._readConfig(configPath); } catch (err) { return callback(err); } const nodeId = utils.getNodeID(config.networkPrivateKey); if (nodeId === null) { return callback(new Error('Invalid Private Key')); } const share = this.shares.get(nodeId) || { config: config, meta: { uptimeMs: 0, farmerState: {}, numRestarts: 0 }, process: null, readyState: 0, path: configPath }; this._log(`attempting to start node with config at path ${configPath}`); if (this.shares.has(nodeId) && this.shares.get(nodeId).readyState === 1) { return callback(new Error(`node ${nodeId} is already running`)); } utils.validateAllocation(share.config, (err) => { if (err) { return callback(new Error(err.message.toLowerCase())); } share.meta.uptimeMs = 0; /* istanbul ignore next */ let uptimeCounter = setInterval(() => share.meta.uptimeMs += 1000, 1000); // NB: Fork the actual farmer process, passing it the configuration share.process = fork( path.join(__dirname, '../script/farmer.js'), ['--config', configPath], { stdio: [0, 'pipe', 'pipe', 'ipc'] } ); share.readyState = RPC.SHARE_STARTED; let loggerOutputFile = !share.config.loggerOutputFile ? path.join(homedir(), '.config/storjshare/logs') : share.config.loggerOutputFile; try { if (!fs.statSync(loggerOutputFile).isDirectory()) { loggerOutputFile = path.dirname(loggerOutputFile); } } catch (err) { loggerOutputFile = path.dirname(loggerOutputFile); } const fslogger = new FsLogger(loggerOutputFile, nodeId); fslogger.setLogLevel(config.logVerbosity); share.process.stderr.on('data', function(data) { fslogger.write(data); }); share.process.stdout.on('data', function(data) { fslogger.write(data); }); // NB: Listen for state changes to update the node's record share.process.on('error', (err) => { share.readyState = RPC.SHARE_ERRORED; this._log(err.message, 'error'); clearInterval(uptimeCounter); }); // NB: Listen for exits and restart the node if not stopped manually share.process.on('exit', (code, signal) => { let maxRestartsReached = share.meta.numRestarts >= RPC.MAX_RESTARTS; share.readyState = RPC.SHARE_STOPPED; this._log(`node ${nodeId} exited with code ${code}`); clearInterval(uptimeCounter); if (signal !== 'SIGINT' && !maxRestartsReached && share.meta.uptimeMs >= 5000 ) { share.meta.numRestarts++; this.restart(nodeId, () => null); } }); share.process.on('message', (msg) => this._processShareIpc(share, msg)); this.shares.set(nodeId, share); callback(null); }); } /** * @callback RPC~startCallback * @param {Error|null} error */ /** * Stops the node process for the given node ID * @param {String} nodeId * @param {RPC~stopCallback} */ stop(nodeId, callback) { this._log(`attempting to stop node with node id ${nodeId}`); if (!this.shares.has(nodeId) || !this.shares.get(nodeId).readyState) { return callback(new Error(`node ${nodeId} is not running`)); } this.shares.get(nodeId).process.kill('SIGINT'); //reset share status if (this.shares.has(nodeId) && 'meta' in this.shares.get(nodeId)) { this.shares.get(nodeId).meta.uptimeMs = 0; this.shares.get(nodeId).meta.numRestarts = 0; this.shares.get(nodeId).meta.peers = 0; } if (this.shares.has(nodeId) && 'meta' in this.shares.get(nodeId) && 'farmerState' in this.shares.get(nodeId).meta) { this.shares.get(nodeId).meta.farmerState.bridgesConnectionStatus = 0; this.shares.get(nodeId).meta.farmerState.totalPeers = 0; } if (this.shares.has(nodeId) && 'meta' in this.shares.get(nodeId) && 'farmerState' in this.shares.get(nodeId).meta && 'ntpStatus' in this.shares.get(nodeId).meta.farmerState) { this.shares.get(nodeId).meta.farmerState.ntpStatus.delta = 0; } setTimeout(() => callback(null), 1000); } /** * @callback RPC~stopCallback * @param {Error|null} error */ /** * Restarts the share process for the given node ID * @param {String} nodeId * @param {RPC~restartCallback} */ restart(nodeId, callback) { this._log(`attempting to restart node with node id ${nodeId}`); if (nodeId === '*') { return async.eachSeries( this.shares.keys(), (nodeId, next) => this.restart(nodeId, next), callback ); } this.stop(nodeId, () => { this.start(this.shares.get(nodeId).path, callback); }); } /** * @callback RPC~restartCallback * @param {Error|null} error */ /** * Returns status information about the running nodes * @param {RPC~statusCallback} */ status(callback) { const statuses = []; this._log(`got status query`); this.shares.forEach((share, nodeId) => { statuses.push({ id: nodeId, config: share.config, state: share.readyState, meta: share.meta, path: share.path }); }); callback(null, statuses); } /** * @callback RPC~statusCallback * @param {Error|null} error * @param {Object} status */ /** * Simply kills the daemon and all managed proccesses */ killall(callback) { this._log(`received kill signal, destroying running nodes`); for (let nodeId of this.shares.keys()) { this.destroy(nodeId, () => null); } callback(); setTimeout(() => process.exit(0), 1000); } /** * Kills the node with the given node ID * @param {String} nodeId * @param {RPC~destroyCallback} */ destroy(nodeId, callback) { this._log(`received destroy command for ${nodeId}`); if (!this.shares.has(nodeId) || !this.shares.get(nodeId).process) { return callback(new Error(`node ${nodeId} is not running`)); } let share = this.shares.get(nodeId); share.process.kill('SIGINT'); this.shares.delete(nodeId); callback(null); } /** * @callback RPC~destroyCallback * @param {Error|null} error */ /** * Saves the current nodes configured * @param {String} writePath * @param {RPC~saveCallback} */ save(writePath, callback) { const snapshot = []; this.shares.forEach((val, nodeId) => { snapshot.push({ path: val.path, id: nodeId }); }); fs.writeFile(writePath, JSON.stringify(snapshot, null, 2), (err) => { if (err) { return callback( new Error(`failed to write snapshot, reason: ${err.message}`) ); } callback(null); }); } /** * @callback RPC~saveCallback * @param {Error|null} error */ /** * Loads a state snapshot file * @param {String} readPath * @param {RPC~loadCallback} */ load(readPath, callback) { fs.readFile(readPath, (err, buffer) => { if (err) { return callback( new Error(`failed to read snapshot, reason: ${err.message}`) ); } let snapshot = null; try { snapshot = JSON.parse(buffer.toString()); } catch (err) { return callback(new Error('failed to parse snapshot')); } async.eachLimit(snapshot, 1, (share, next) => { this.start(share.path, (err) => { /* istanbul ignore if */ if (err) { this._log(err.message, 'warn'); } next(); }); }, callback); }); } /** * @callback RPC~loadCallback * @param {Error|null} error */ /** * Returns the number of nodes currently running * @private */ get running() { let i = 0; for (let [, share] of this.shares) { if (share.readyState !== 1) { continue; } else { i++; } } return i; } get methods() { return { start: this.start.bind(this), stop: this.stop.bind(this), restart: this.restart.bind(this), status: this.status.bind(this), killall: this.killall.bind(this), destroy: this.destroy.bind(this), save: this.save.bind(this), load: this.load.bind(this) }; } } RPC.SHARE_STARTED = 1; RPC.SHARE_STOPPED = 0; RPC.SHARE_ERRORED = 2; RPC.MAX_RESTARTS = 30; module.exports = RPC;