UNPKG

ipfsd-ctl

Version:

Spawn IPFS Daemons, Kubo or...

345 lines (280 loc) 9.02 kB
import fs from 'node:fs/promises' import { logger } from '@libp2p/logger' import { execa } from 'execa' // @ts-expect-error needs https://github.com/schnittstabil/merge-options/pull/28 import mergeOptions from 'merge-options' import pDefer from 'p-defer' import waitFor from 'p-wait-for' import { checkForRunningApi, tmpDir, buildStartArgs, repoExists, buildInitArgs, getGatewayAddress } from './utils.js' import type { KuboNode, KuboInfo, KuboInitOptions, KuboOptions, KuboStartOptions, KuboStopOptions } from './index.js' import type { Logger } from '@libp2p/interface' import type { ResultPromise } from 'execa' import type { KuboRPCClient } from 'kubo-rpc-client' const log = logger('ipfsd-ctl:kubo:daemon') const merge = mergeOptions.bind({ ignoreUndefined: true }) function translateError (err: Error & { stdout: string, stderr: string }): Error { // 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 } /** * Node for daemon nodes */ export default class KuboDaemon implements KuboNode { public options: KuboOptions & Required<Pick<KuboOptions, 'rpc'>> private readonly disposable: boolean private subprocess?: ResultPromise private _api?: KuboRPCClient private readonly repo: string private readonly stdout: Logger private readonly stderr: Logger private readonly _exec?: string private readonly env: Record<string, string> private readonly initArgs?: KuboInitOptions private readonly startArgs?: KuboStartOptions private readonly stopArgs?: KuboStopOptions constructor (options: KuboOptions) { if (options.rpc == null) { throw new Error('Please pass an rpc option') } // @ts-expect-error cannot detect rpc is present this.options = options this.repo = options.repo ?? tmpDir(options.type) this._exec = this.options.bin this.env = merge({ IPFS_PATH: this.repo }, this.options.env) this.disposable = Boolean(this.options.disposable) this.stdout = logger('ipfsd-ctl:kubo:stdout') this.stderr = logger('ipfsd-ctl:kubo:stderr') if (options.init != null && typeof options.init !== 'boolean') { this.initArgs = options.init } if (options.start != null && typeof options.start !== 'boolean') { this.startArgs = options.start } if (options.stop != null) { this.stopArgs = options.stop } } get api (): KuboRPCClient { if (this._api == null) { throw new Error('Not started') } return this._api } get exec (): string { if (this._exec == null) { throw new Error('No executable specified') } return this._exec } async info (): Promise<KuboInfo> { const id = await this._api?.id() const info = { version: await this.getVersion(), pid: this.subprocess?.pid, peerId: id?.id.toString(), multiaddrs: (id?.addresses ?? []).map(ma => ma.toString()), api: checkForRunningApi(this.repo), repo: this.repo, gateway: getGatewayAddress(this.repo) } log('info %s %s %p %s', info.version, info.pid, info.peerId, info.repo) return info } /** * Delete the repo that was being used. If the node was marked as disposable * this will be called automatically when the process is exited. */ async cleanup (): Promise<void> { try { await fs.rm(this.repo, { recursive: true, force: true, maxRetries: 10 }) } catch (err: any) { if (err.code !== 'EPERM') { throw err } } } async init (args?: KuboInitOptions): Promise<void> { // check if already initialized if (await repoExists(this.repo)) { log('repo already exists') return } const initOptions = { ...(this.initArgs ?? {}), ...(args ?? {}) } if (this.options.test === true) { if (initOptions.profiles == null) { initOptions.profiles = [] } if (!initOptions.profiles.includes('test')) { initOptions.profiles.push('test') } } const cliArgs = buildInitArgs(initOptions) log('init exec %s %s', this.exec, cliArgs.join(' ')) const out = await execa(this.exec, cliArgs, { env: this.env }) .catch(translateError) if (out instanceof Error) { log('error initting %s - %e', this.exec, out) throw out } const { stdout, stderr } = out this.stdout(stdout) this.stderr(stderr) log('replace config') await this._replaceConfig(merge( await this._getConfig(), initOptions.config )) } /** * Start the daemon */ async start (args?: KuboStartOptions): Promise<void> { // Check if a daemon is already running const api = checkForRunningApi(this.repo) if (api != null) { this._api = this.options.rpc(api) return } const startOptions = { ...(this.startArgs ?? {}), ...(args ?? {}) } const cliArgs = buildStartArgs(startOptions) let output = '' const deferred = pDefer() log('start exec %s %s', this.exec, cliArgs.join(' ')) const out = this.subprocess = execa(this.exec, cliArgs, { env: this.env }) if (out instanceof Error) { log('error starting %s - %e', this.exec, out) throw out } const { stdout, stderr } = out if (stderr == null || stdout == null) { throw new Error('stdout/stderr was not defined on subprocess') } stderr.on('data', data => { this.stderr(data.toString()) }) stdout.on('data', data => { this.stdout(data.toString()) }) const readyHandler = (data: Buffer): void => { output += data.toString() const apiMatch = output.trim().match(/API .*listening on:? (.*)/) if ((apiMatch != null) && apiMatch.length > 0) { this._api = this.options.rpc(apiMatch[1]) } if (output.match(/(?:daemon is running|Daemon is ready)/) != null) { // we're good stdout.off('data', readyHandler) deferred.resolve() } } stdout.on('data', readyHandler) this.subprocess.catch(err => { deferred.reject(translateError(err)) }) // remove listeners and clean up on process exit void this.subprocess.on('exit', () => { stderr.removeAllListeners() stdout.removeAllListeners() if (this.disposable) { this.cleanup().catch(() => {}) } }) await deferred.promise } async stop (options?: KuboStopOptions): Promise<void> { const stopOptions = { ...(this.stopArgs ?? {}), ...(options ?? {}) } const timeout = stopOptions.forceKillTimeout ?? 1000 const subprocess = this.subprocess if (subprocess == null || subprocess.exitCode != null || this._api == null) { return } try { log('stop node') await this.api.stop() // wait for the subprocess to exit and declare ourselves stopped await waitFor(() => subprocess.exitCode != null, { timeout }) } catch (err) { log('error stopping %s - %e', this.exec, err) subprocess.kill('SIGKILL') } if (this.disposable) { // wait for the cleanup routine to run after the subprocess has exited await this.cleanup() } } /** * Call `ipfs config` * * If no `key` is passed, the whole config is returned as an object. */ async _getConfig (): Promise<any> { const contents = await fs.readFile(`${this.repo}/config`, { encoding: 'utf-8' }) const config = JSON.parse(contents) if (this.options.test === true) { // use random ports for all addresses config.Addresses.Swarm = [ '/ip4/127.0.0.1/tcp/0', '/ip4/127.0.0.1/tcp/0/ws', '/ip4/127.0.0.1/udp/0/quic-v1', '/ip4/127.0.0.1/udp/0/quic-v1/webtransport', '/ip4/127.0.0.1/tcp/0/webrtc-direct' ] config.Addresses.API = '/ip4/127.0.0.1/tcp/0' config.Addresses.Gateway = '/ip4/127.0.0.1/tcp/0' // configure CORS access for the http api config.API.HTTPHeaders = { 'Access-Control-Allow-Origin': ['*'], 'Access-Control-Allow-Methods': ['PUT', 'POST', 'GET'] } } return config } /** * Replace the current config with the provided one */ public async _replaceConfig (config: any): Promise<void> { await fs.writeFile(`${this.repo}/config`, JSON.stringify(config, null, 2), { encoding: 'utf-8' }) } private async getVersion (): Promise<string> { if (this.exec == null) { throw new Error('No executable specified') } log('getVersion exec %s version', this.exec) const out = await execa(this.exec, ['version'], { env: this.env }) .catch(translateError) if (out instanceof Error) { log('error getting version %s - %e', this.exec, out) throw out } const { stdout } = out const version = stdout.trim() log('getVersion version %s', version) return version } }