UNPKG

ipsambeatae

Version:

Shared dependencies of Compass, the MongoDB extension for VSCode and MongoSH

287 lines (263 loc) 8.89 kB
import type { ChildProcess } from 'child_process'; import { spawn } from 'child_process'; import { promises as fs, createWriteStream } from 'fs'; import type { LogEntry } from './mongologreader'; import { createLogEntryIterator, filterLogStreamForPort, isFailureToSetupListener, } from './mongologreader'; import { Readable } from 'stream'; import type { Document } from 'mongodb'; import { MongoClient } from 'mongodb'; import path from 'path'; import { once } from 'events'; import { uuid, debug } from './util'; export interface MongoServerOptions { binDir?: string; binary: string; // 'mongod', 'mongos', etc. tmpDir: string; // Stores e.g. database contents logDir?: string; // If set, pipe log file output through here. args?: string[]; // May or may not contain --port docker?: string; // Image } export class MongoServer { private buildInfo?: Document; private childProcess?: ChildProcess; private pid?: number; private port?: number; private dbPath?: string; private closing = false; private constructor() { /* see .start() */ } serialize(): unknown /* JSON-serializable */ { return { pid: this.pid, port: this.port, dbPath: this.dbPath, }; } static async deserialize(serialized: any): Promise<MongoServer> { const srv = new MongoServer(); srv.pid = serialized.pid; srv.port = serialized.port; srv.dbPath = serialized.dbPath; await srv._populateBuildInfo(); return srv; } // Throws before .start() and after .close() get hostport(): string { if (this.port === undefined) { throw new Error('Cannot get host/port for closed server'); } return `127.0.0.1:${this.port}`; } // Returns the version reported in the server log output get serverVersion(): string { if (!this.buildInfo) { throw new Error('Cannot get version for closed server'); } return this.buildInfo.version; } get serverVariant(): 'enterprise' | 'community' { if (!this.buildInfo) { throw new Error('Cannot get version for closed server'); } return this.buildInfo.modules?.includes?.('enterprise') ? 'enterprise' : 'community'; } static async start({ ...options }: MongoServerOptions): Promise<MongoServer> { if (options.binary === 'mongos' && !options.args?.includes('--port')) { // SERVER-78384: mongos does not understand `--port 0` ... // Just pick a random port in [1024, 49152), try to listen, and continue until // we find a free one. const minPort = 1025; const maxPort = 49151; let port = Math.floor(Math.random() * (maxPort - minPort) + minPort); // eslint-disable-next-line no-constant-condition while (true) { try { debug('Trying to spawn mongos on port', port); return await this._start({ ...options, args: [...(options.args ?? []), '--port', String(port)], }); } catch (err) { if ( ((err as any).errorLogEntries as LogEntry[]).some( isFailureToSetupListener ) ) { if (port === maxPort) port = minPort; else port++; continue; } throw err; } } } return await this._start(options); } static async _start({ ...options }: MongoServerOptions): Promise<MongoServer> { const srv = new MongoServer(); if (!options.docker) { const dbPath = path.join(options.tmpDir, `db-${uuid()}`); await fs.mkdir(dbPath, { recursive: true }); srv.dbPath = dbPath; } const commandline: string[] = []; if (options.docker) { commandline.push('docker', 'run', '--network=host'); if (options.binDir) { commandline.push(`--volume=${options.binDir}:/runner-bin:ro`); } commandline.push(options.docker); if (options.binDir) { commandline.push(`/runner-bin/${options.binary}`); } else { commandline.push(options.binary); } } else if (options.binDir) { commandline.push(path.join(options.binDir, options.binary)); } else { commandline.push(options.binary); } commandline.push(...(options.args ?? [])); if (!options.args?.includes('--port')) commandline.push('--port', '0'); if (!options.args?.includes('--dbpath') && options.binary === 'mongod') commandline.push('--dbpath', options.docker ? '/tmp' : srv.dbPath!); if ( !options.args?.includes('--unixSocketPrefix') && process.platform !== 'win32' ) commandline.push('--nounixsocket'); debug('starting server', commandline); const [executable, ...args] = commandline; const proc = spawn(executable, args, { stdio: ['inherit', 'pipe', 'pipe'], cwd: options.tmpDir, detached: true, }); await once(proc, 'spawn'); srv.childProcess = proc; srv.pid = proc.pid; const { stdout, stderr } = proc; stdout.setEncoding('utf8'); stderr.setEncoding('utf8'); if (options.logDir) { const outFile = path.join( options.logDir, `${options.binary}-${String(proc.pid)}-${new Date() .toISOString() .replace(/[^-_a-zA-Z0-9.]/g, '')}.log` ); await fs.mkdir(options.logDir, { recursive: true }); const outStream = createWriteStream(outFile); await once(outStream, 'open'); stdout.pipe(outStream, { end: false }); stderr.pipe(outStream, { end: false }); Promise.all([once(stdout, 'end'), once(stderr, 'end')]).then( () => outStream.end(), () => { /* ignore error */ } ); } else { stderr.on('data', (chunk) => debug('server stderr', chunk)); stderr.resume(); stdout.resume(); } const errorLogEntries: LogEntry[] = []; try { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const logEntryStream = Readable.from(createLogEntryIterator(stdout)); logEntryStream.on('data', (entry) => { if (!srv.closing && ['E', 'F'].includes(entry.severity)) { errorLogEntries.push(entry); debug('mongodb server output', entry); } }); const { port } = await filterLogStreamForPort(logEntryStream); debug('server listening on port', port); if (port === -1) { // This likely means that stdout ended before we could get a path/port // from it, most likely because spawning itself failed. let message = 'Server log output did not include port or socket'; if (errorLogEntries.length > 0) { const format = (entry: LogEntry) => `${entry.message} ${JSON.stringify(entry.attr)}`; message = `Server failed to start: ${errorLogEntries .map(format) .join(', ')} from ${commandline.join(' ')})`; } const err: Error & { errorLogEntries?: LogEntry[] } = new Error( message ); err.errorLogEntries = errorLogEntries; throw err; } logEntryStream.resume(); srv.port = port; await srv._populateBuildInfo(); } catch (err) { await srv.close(); throw err; } return srv; } async close(): Promise<void> { this.closing = true; if (this.childProcess) { debug('closing running process', this.childProcess.pid); if (this.childProcess.exitCode === null) { this.childProcess.kill('SIGKILL'); await once(this.childProcess, 'exit'); } debug('stopped running process'); this.childProcess = undefined; } else if (this.pid !== undefined) { debug('closing externally started process', this.pid); process.kill(this.pid, 'SIGKILL'); } try { if (this.dbPath) await fs.rm(this.dbPath, { recursive: true }); } catch (err) { debug('failed to remove dbPath', err); } this.buildInfo = undefined; this.port = undefined; this.dbPath = undefined; } private async _populateBuildInfo(): Promise<void> { this.buildInfo = await this.withClient( async (client) => await client.db('admin').command({ buildInfo: 1 }) ); debug('got server build info', this.serverVersion, this.serverVariant); } async withClient<Fn extends (client: MongoClient) => any>( fn: Fn ): Promise<ReturnType<Fn>> { const client = await MongoClient.connect( `mongodb://${this.hostport}/?directConnection=true` ); try { return await fn(client); } finally { await client.close(true); } } ref(): void { this.childProcess?.ref(); (this.childProcess?.stdout as any)?.ref(); (this.childProcess?.stderr as any)?.ref(); } unref(): void { this.childProcess?.unref(); (this.childProcess?.stdout as any)?.unref(); (this.childProcess?.stderr as any)?.unref(); } }