UNPKG

ipfsd-ctl

Version:
426 lines (361 loc) 10.3 kB
import { multiaddr } from '@multiformats/multiaddr' import fs from 'fs/promises' import mergeOptions from 'merge-options' import { logger } from '@libp2p/logger' import { execa } from 'execa' import { nanoid } from 'nanoid' import path from 'path' import os from 'os' import { checkForRunningApi, repoExists, tmpDir, defaultRepo, buildInitArgs, buildStartArgs } from './utils.js' import waitFor from 'p-wait-for' /** * @typedef {import('@multiformats/multiaddr').Multiaddr} Multiaddr */ const merge = mergeOptions.bind({ ignoreUndefined: true }) const daemonLog = { info: logger('ipfsd-ctl:daemon:stdout'), err: logger('ipfsd-ctl:daemon:stderr') } /** * @param {Error & { stdout: string, stderr: string }} err */ function translateError (err) { // get the actual error message to be the err.message err.message = `${err.stdout} \n\n ${err.stderr} \n\n ${err.message} \n\n` return err } /** * @typedef {import('./types').ControllerOptions} ControllerOptions * @typedef {import('./types').Controller} Controller */ /** * Controller for daemon nodes * * @class * */ class Daemon { /** * @class * @param {Required<ControllerOptions>} opts */ constructor (opts) { this.opts = opts this.path = this.opts.ipfsOptions.repo || (opts.disposable ? tmpDir(opts.type) : defaultRepo(opts.type)) this.exec = this.opts.ipfsBin this.env = merge({ IPFS_PATH: this.path }, this.opts.env) this.disposable = this.opts.disposable this.subprocess = null this.initialized = false this.started = false this.clean = true /** @type {Multiaddr} */ this.apiAddr // eslint-disable-line no-unused-expressions this.grpcAddr = null this.gatewayAddr = null this.api = null /** @type {import('./types').PeerData | null} */ this._peerId = null } get peer () { if (this._peerId == null) { throw new Error('Not started') } return this._peerId } /** * @private * @param {string} addr */ _setApi (addr) { this.apiAddr = multiaddr(addr) } /** * @private * @param {string} addr */ _setGrpc (addr) { this.grpcAddr = multiaddr(addr) } /** * @private * @param {string} addr */ _setGateway (addr) { this.gatewayAddr = multiaddr(addr) } _createApi () { if (this.opts.ipfsClientModule && this.grpcAddr) { this.api = this.opts.ipfsClientModule.create({ grpc: this.grpcAddr, http: this.apiAddr }) } else if (this.apiAddr) { this.api = this.opts.ipfsHttpModule.create(this.apiAddr) } if (!this.api) { throw new Error(`Could not create API from http '${this.apiAddr}' and/or gRPC '${this.grpcAddr}'`) } if (this.apiAddr) { this.api.apiHost = this.apiAddr.nodeAddress().address this.api.apiPort = this.apiAddr.nodeAddress().port } if (this.gatewayAddr) { this.api.gatewayHost = this.gatewayAddr.nodeAddress().address this.api.gatewayPort = this.gatewayAddr.nodeAddress().port } if (this.grpcAddr) { this.api.grpcHost = this.grpcAddr.nodeAddress().address this.api.grpcPort = this.grpcAddr.nodeAddress().port } } /** * Initialize a repo. * * @param {import('./types').InitOptions} [initOptions={}] * @returns {Promise<Controller>} */ async init (initOptions = {}) { this.initialized = await repoExists(this.path) if (this.initialized) { this.clean = false return this } initOptions = merge({ emptyRepo: false, profiles: this.opts.test ? ['test'] : [] }, typeof this.opts.ipfsOptions.init === 'boolean' ? {} : this.opts.ipfsOptions.init, typeof initOptions === 'boolean' ? {} : initOptions ) const opts = merge( this.opts, { ipfsOptions: { init: initOptions } } ) const args = buildInitArgs(opts) const { stdout, stderr } = await execa(this.exec, args, { env: this.env }) .catch(translateError) daemonLog.info(stdout) daemonLog.err(stderr) // default-config only for Go if (this.opts.type === 'go') { await this._replaceConfig(merge( await this._getConfig(), this.opts.ipfsOptions.config )) } this.clean = false this.initialized = true return this } /** * Delete the repo that was being used. If the node was marked as disposable this will be called automatically when the process is exited. * * @returns {Promise<Daemon>} */ async cleanup () { if (!this.clean) { await fs.rm(this.path, { recursive: true }) this.clean = true } return this } /** * Start the daemon. * * @returns {Promise<Daemon>} */ async start () { // Check if a daemon is already running const api = checkForRunningApi(this.path) if (api) { this._setApi(api) this._createApi() } else if (!this.exec) { throw new Error('No executable specified') } else { const args = buildStartArgs(this.opts) let output = '' const ready = new Promise((resolve, reject) => { this.subprocess = execa(this.exec, args, { env: this.env }) const { stdout, stderr } = this.subprocess if (!stderr) { throw new Error('stderr was not defined on subprocess') } if (!stdout) { throw new Error('stderr was not defined on subprocess') } stderr.on('data', data => daemonLog.err(data.toString())) stdout.on('data', data => daemonLog.info(data.toString())) /** * @param {Buffer} data */ const readyHandler = data => { output += data.toString() const apiMatch = output.trim().match(/API .*listening on:? (.*)/) const gwMatch = output.trim().match(/Gateway .*listening on:? (.*)/) const grpcMatch = output.trim().match(/gRPC .*listening on:? (.*)/) if (apiMatch && apiMatch.length > 0) { this._setApi(apiMatch[1]) } if (gwMatch && gwMatch.length > 0) { this._setGateway(gwMatch[1]) } if (grpcMatch && grpcMatch.length > 0) { this._setGrpc(grpcMatch[1]) } if (output.match(/(?:daemon is running|Daemon is ready)/)) { // we're good this._createApi() this.started = true stdout.off('data', readyHandler) resolve(this.api) } } stdout.on('data', readyHandler) this.subprocess.catch(err => reject(translateError(err))) this.subprocess.on('exit', () => { this.started = false stderr.removeAllListeners() stdout.removeAllListeners() if (this.disposable) { this.cleanup().catch(() => {}) } }) }) await ready } this.started = true // Add `peerId` const id = await this.api.id() this._peerId = id return this } /** * Stop the daemon. * * @param {object} [options] * @param {number} [options.timeout=60000] - How long to wait for the daemon to stop * @returns {Promise<Daemon>} */ async stop (options = {}) { const timeout = options.timeout || 60000 if (!this.started) { return this } if (this.subprocess) { /** @type {ReturnType<setTimeout> | undefined} */ let killTimeout const subprocess = this.subprocess if (this.disposable) { // we're done with this node and will remove it's repo when we are done // so don't wait for graceful exit, just terminate the process this.subprocess.kill('SIGKILL') } else { if (this.opts.forceKill !== false) { killTimeout = setTimeout(() => { // eslint-disable-next-line no-console console.error(new Error(`Timeout stopping ${this.opts.type} node after ${this.opts.forceKillTimeout}ms. Process ${subprocess.pid} will be force killed now.`)) this.subprocess && this.subprocess.kill('SIGKILL') }, this.opts.forceKillTimeout) } this.subprocess.cancel() } // wait for the subprocess to exit and declare ourselves stopped await waitFor(() => !this.started, { timeout }) if (killTimeout) { clearTimeout(killTimeout) } if (this.disposable) { // wait for the cleanup routine to run after the subprocess has exited await waitFor(() => this.clean, { timeout }) } } else { await this.api.stop() this.started = false } return this } /** * Get the pid of the `ipfs daemon` process. * * @returns {Promise<number>} */ pid () { if (this.subprocess && this.subprocess.pid != null) { return Promise.resolve(this.subprocess.pid) } throw new Error('Daemon process is not running.') } /** * Call `ipfs config` * * If no `key` is passed, the whole config is returned as an object. * * @private * @param {string} [key] - A specific config to retrieve. * @returns {Promise<object | string>} */ async _getConfig (key = 'show') { const { stdout } = await execa( this.exec, ['config', key], { env: this.env }) .catch(translateError) if (key === 'show') { return JSON.parse(stdout) } return stdout.trim() } /** * Replace the current config with the provided one * * @private * @param {object} config * @returns {Promise<Daemon>} */ async _replaceConfig (config) { const tmpFile = path.join(os.tmpdir(), nanoid()) await fs.writeFile(tmpFile, JSON.stringify(config)) await execa( this.exec, ['config', 'replace', `${tmpFile}`], { env: this.env } ) .catch(translateError) await fs.unlink(tmpFile) return this } /** * Get the version of ipfs * * @returns {Promise<string>} */ async version () { const { stdout } = await execa(this.exec, ['version'], { env: this.env }) .catch(translateError) return stdout.trim() } } export default Daemon