quosdolorem
Version:
The MongoDB Shell
555 lines (499 loc) • 18.1 kB
text/typescript
import child_process from 'child_process';
import crypto from 'crypto';
import { once } from 'events';
import { promises as fs } from 'fs';
import { MongoClient, MongoClientOptions } from 'mongodb';
import path from 'path';
import rimraf from 'rimraf';
import semver from 'semver';
import { URL } from 'url';
import { promisify } from 'util';
import which from 'which';
import { downloadMongoDb } from '../packages/build/src/download-mongodb';
const execFile = promisify(child_process.execFile);
const isCI = !!process.env.IS_CI;
function ciLog(...args: any[]) {
if (isCI) {
console.error(...args);
}
}
// Return the stat results or, if the file does not exist, `undefined`.
async function statIfExists(path: string): Promise<ReturnType<typeof fs.stat> | undefined> {
try {
return await fs.stat(path);
} catch (err) {
if (err.code === 'ENOENT') {
return undefined;
}
throw err;
}
}
// Return the path to the temporary directory and ensure that it exists.
async function getTmpdir(): Promise<string> {
const tmpdir = path.resolve(__dirname, '..', 'tmp');
await fs.mkdir(tmpdir, { recursive: true });
return tmpdir;
}
async function tryExtensions(base: string): Promise<[ string, Error ]> {
let lastErr = new Error('unreachable');
for (const ext of ['', '.exe', '.bat']) {
try {
await fs.stat(base + ext);
return [ base + ext, lastErr ];
} catch (err) {
lastErr = err;
ciLog('File does not exist or is inaccessible', base + ext);
}
}
return [ '', lastErr ];
}
async function findPython3() {
try {
await which('python3');
return 'python3';
} catch {
ciLog('Did not find python3 in PATH');
try {
// Fun fact on the side: Python 2.x writes the version to stderr,
// Python 3.x writes to stdout.
const { stdout } = await execFile('python', ['-V']);
if (stdout.includes('Python 3')) {
return 'python';
} else {
throw new Error('python is not Python 3.x');
}
} catch {
ciLog('Did not find python as Python 3.x in PATH');
const pythonEnv = process.env.PYTHON;
if (pythonEnv) {
const { stdout } = await execFile(pythonEnv as string, ['-V']);
if (stdout.includes('Python 3')) {
return pythonEnv as string;
}
}
}
}
throw new Error('Could not find Python 3.x installation, install mlaunch manually');
}
// Get the path we use to spawn mlaunch, and potential environment variables
// necessary to run it successfully. This tries to install it locally if it
// cannot find an existing installation.
let mlaunchPath: { exec: string[], env: Record<string,string> } | undefined;
export async function getMlaunchPath(): Promise<{ exec: string[], env: Record<string,string> }> {
const tmpdir = await getTmpdir();
if (mlaunchPath !== undefined) {
return mlaunchPath;
}
try {
// If `mlaunch` is already in the PATH: Great, we're done.
const mlaunchBinary = await which('mlaunch');
const filehandle: any = await fs.open(mlaunchBinary, 'r');
try {
const startOfMlaunchFile = (await filehandle.read({ length: 100 })).buffer.toString('utf8').trim();
if (startOfMlaunchFile.match(/^#!(\S+)python3?\r?\n/)) {
return mlaunchPath = { exec: [ await findPython3(), mlaunchBinary ], env: {} };
}
} finally {
await filehandle.close();
}
return mlaunchPath = { exec: [ mlaunchBinary ], env: {} };
} catch {
ciLog('Did not find mlaunch in PATH');
}
// Figure out where python3 might live (python3, python, $PYTHON).
const python = await findPython3();
// Install mlaunch, preferably locally and otherwise attempt to do so globally.
try {
const mlaunchPy = path.join(tmpdir, 'bin', 'mlaunch');
let [ exec ] = await tryExtensions(mlaunchPy);
if (exec) {
return mlaunchPath = { exec: [ python, exec ], env: { PYTHONPATH: tmpdir } };
}
ciLog('Trying to install mlaunch in ', tmpdir);
await execFile('pip3', ['install', '--target', tmpdir, 'mtools[mlaunch]']);
ciLog('Installation complete');
[ exec ] = await tryExtensions(mlaunchPy);
if (exec) {
return mlaunchPath = { exec: [ python, exec ], env: { PYTHONPATH: tmpdir } };
}
} catch {}
// Figure out the most likely target path for pip3 --user and use mlaunch
// from there.
const pythonBase = (await execFile(python, ['-m', 'site', '--user-base'])).stdout.trim();
const pythonPath = (await execFile(python, ['-m', 'site', '--user-site'])).stdout.trim();
const mlaunchExec = path.join(pythonBase, 'bin', 'mlaunch');
{
const [ exec ] = await tryExtensions(mlaunchExec);
if (exec) {
return { exec: [ python, exec ], env: { PYTHONPATH: pythonPath } };
}
}
ciLog('Trying to install mlaunch in ', { pythonBase, pythonPath });
await execFile('pip3', ['install', '--user', 'mtools[mlaunch]']);
ciLog('Installation complete');
const [ exec, lastErr ] = await tryExtensions(mlaunchExec);
if (exec) {
return { exec: [ python, exec ], env: { PYTHONPATH: pythonPath } };
}
throw lastErr;
}
type MlaunchCommand = 'init' | 'start' | 'stop' | 'list' | 'kill';
// Run a specific mlaunch command, trying to ensure that `mlaunch` is accessible
// and installing it if necessary.
async function execMlaunch(command: MlaunchCommand, ...args: string[]): Promise<void> {
const mlaunchPath = await getMlaunchPath();
// command could be part of args, but the extra typechecking here has helped
// me at least once.
const fullCmd = [...mlaunchPath.exec, command, ...args];
// console.info('Running command', fullCmd.join(' '));
const proc = child_process.spawn(fullCmd[0], fullCmd.slice(1), {
env: { ...process.env, ...mlaunchPath.env },
stdio: 'inherit'
});
await once(proc, 'exit');
// console.info('Successfully ran command', fullCmd.join(' '));
}
// Remove all potential leftover mlaunch instances.
export async function clearMlaunch({ killAllMongod = false } = {}) {
if (killAllMongod) {
for (const proc of ['mongod', 'mongos']) {
try {
if (process.platform === 'win32') {
await execFile('taskkill', ['/IM', `${proc}.exe`, '/F']);
} else {
await execFile('killall', [proc]);
}
} catch (err) {
console.warn(`Cleaning up ${proc} instances failed:`, err);
}
}
}
const tmpdir = await getTmpdir();
for await (const { name } of await fs.opendir(tmpdir)) {
if (name.startsWith('mlaunch-')) {
const fullPath = path.join(tmpdir, name);
try {
await execMlaunch('kill', '--dir', fullPath);
} catch (err) {
console.warn(`mlaunch kill in ${fullPath} failed:`, err);
}
try {
await promisify(rimraf)(fullPath);
} catch (err) {
console.warn(`Removing ${fullPath} failed:`, err);
}
}
}
await promisify(rimraf)(path.join(tmpdir, '.current-port'));
}
// Represents one running test server instance.
export class MongodSetup {
_connectionString: Promise<string>;
_setConnectionString: (connectionString: string) => void;
_serverVersion: string | null = null;
_isCommunityServer: boolean | null = null;
_bindir = '';
constructor(connectionString?: string) {
this._setConnectionString = (connectionString: string) => {}; // Make TypeScript happy.
this._connectionString = new Promise(resolve => {
this._setConnectionString = resolve;
});
if (connectionString) {
this._setConnectionString(connectionString);
}
}
async start(): Promise<void> {
throw new Error('Server not managed');
}
async stop(): Promise<void> {
throw new Error('Server not managed');
}
async connectionString(): Promise<string> {
return this._connectionString;
}
async host(): Promise<string> {
return new URL(await this.connectionString()).hostname;
}
async port(): Promise<string> {
// 27017 is the default port for mongodb:// URLs.
return new URL(await this.connectionString()).port ?? '27017';
}
async hostport(): Promise<string> {
return `${await this.host()}:${await this.port()}`;
}
async serverVersion(): Promise<string> {
if (this._serverVersion !== null) {
return this._serverVersion;
}
const { version } = await this.withClient(async client => {
return await client.db('db1').admin().serverStatus();
});
this._serverVersion = version;
return version;
}
async isCommunityServer(): Promise<boolean> {
if (this._isCommunityServer !== null) {
return this._isCommunityServer;
}
const { modules } = await this.withClient(async client => {
return await client.db('db1').admin().command({ buildInfo: 1 });
});
const isCommunityServer = !modules.includes('enterprise');
this._isCommunityServer = isCommunityServer;
return isCommunityServer;
}
async withClient<T>(fn: (client: MongoClient) => Promise<T>): Promise<T> {
let client;
try {
client = await MongoClient.connect(await this.connectionString(), {});
return await fn(client);
} finally {
if (client) {
client.close();
}
}
}
get bindir(): string {
return this._bindir;
}
}
// Spawn mlaunch with a specific set of arguments.
class MlaunchSetup extends MongodSetup {
_args: string[];
_mlaunchdir = '';
constructor(args: string[] = []) {
super();
this._args = args;
}
async start(): Promise<void> {
if (this._mlaunchdir) return;
const random = (await promisify(crypto.randomBytes)(16)).toString('hex');
const tag = `${process.pid}-${random}`;
const tmpdir = await getTmpdir();
this._mlaunchdir = path.join(tmpdir, `mlaunch-${tag}`);
const args = this._args;
if (!args.includes('--replicaset') && !args.includes('--single')) {
args.push('--single');
}
let port: number;
if (args.includes('--port')) {
const index = args.indexOf('--port');
port = +args.splice(index, 2)[1];
} else {
// If no port was specified, we pick one in the range [30000, 40000].
// We pick by writing to a file, looking up the index at which we wrote,
// and adding that to the base port, so that there is a low likelihood of
// port collisions between different test runs even when two tests call
// startMlaunch() at the same time.
// Ideally, we would handle failures from port conflicts that occur when
// mlaunch starts, but we don't currently have access to that information
// until .start() is called.
const portfile = path.join(tmpdir, '.current-port');
await fs.appendFile(portfile, `${tag}\n`);
const portfileContent = (await fs.readFile(portfile, 'utf8')).split('\n');
const writeIndex = portfileContent.indexOf(tag);
if (writeIndex === -1) {
throw new Error(`Could not figure out port number, ${portfile} may be corrupt`);
}
port = 30000 + (writeIndex * 30) % 10000;
}
// Make sure mongod and mongos are accessible
const binarypath = await ensureMongodAvailable();
if (binarypath) {
args.unshift('--binarypath', binarypath);
this._bindir = binarypath;
}
if (await statIfExists(this._mlaunchdir)) {
// There might be leftovers from previous runs. Remove them.
await execMlaunch('kill', '--dir', this._mlaunchdir);
await promisify(rimraf)(this._mlaunchdir);
}
await fs.mkdir(this._mlaunchdir, { recursive: true });
await execMlaunch('init', '--dir', this._mlaunchdir, '--port', `${port}`, ...args);
this._setConnectionString(`mongodb://localhost:${port}`);
}
async stop(): Promise<void> {
if (!this._mlaunchdir) return;
await execMlaunch('stop', '--dir', this._mlaunchdir);
try {
await promisify(rimraf)(this._mlaunchdir);
} catch (err) {
console.error(`Cannot remove directory ${this._mlaunchdir}`, err);
}
}
}
async function getInstalledMongodVersion(): Promise<string> {
await Promise.all([which('mongod'), which('mongos')]);
const { stdout } = await execFile('mongod', ['--version']);
const { version } = stdout.match(/^db version (?<version>.+)$/m)!.groups as any;
return version;
}
export async function ensureMongodAvailable(mongodVersion = process.env.MONGOSH_SERVER_TEST_VERSION): Promise<string | null> {
try {
if (/-community/.test(String(mongodVersion))) {
console.info(`Explicitly requesting community server with ${mongodVersion}, downloading...`);
throw new Error();
}
const version = await getInstalledMongodVersion();
if (mongodVersion && !semver.satisfies(version, mongodVersion)) {
console.info(`global mongod is ${version}, wanted ${mongodVersion}, downloading...`);
throw new Error();
}
return null;
} catch {
return await downloadMongoDb(path.resolve(__dirname, '..', 'tmp'), mongodVersion);
}
}
/**
* Starts a local server unless the `MONGOSH_TEST_SERVER_URL`
* environment variable is set.
*
* It returns an object with information that can be used to connect to the
* server.
*
* If env.MONGOSH_TEST_SERVER_URL is set it assumes a server
* is already running on that uri and returns an object whose
* .connectionString() method points to the contents of that environment
* variable.
*
* If `shareMode` is `shared`, then no arguments may be passed. In that case,
* this may re-use an existing test server managed by this process.
*
* @export
* @returns {MongodSetup} - Object with information about the started server.
*/
let sharedSetup : MongodSetup | null = null;
export function startTestServer(shareMode: 'shared' | 'not-shared', ...args: string[]): MongodSetup {
if (shareMode === 'shared' && process.env.MONGOSH_TEST_SERVER_URL) {
return new MongodSetup(process.env.MONGOSH_TEST_SERVER_URL);
}
let server : MongodSetup;
if (shareMode === 'shared') {
if (args.length > 0) {
throw new Error('Cannot specify arguments for shared mongod');
}
server = sharedSetup ?? (sharedSetup = new MlaunchSetup());
} else {
server = new MlaunchSetup(args);
}
before(async function() {
this.timeout(120_000); // Include potential mongod download time.
await server.start();
});
after(async function() {
// Clean the shared server only up once we're done with everything.
if (shareMode !== 'shared') {
this.timeout(30_000);
await server.stop();
}
});
return server;
}
global.after?.(async function() {
if (sharedSetup !== null) {
this.timeout(30_000);
await sharedSetup.stop();
}
});
// The same as startTestServer(), except that this starts multiple servers
// in parallel in the same before() call.
export function startTestCluster(...argLists: string[][]): MongodSetup[] {
const servers = argLists.map(args => new MlaunchSetup(args));
before(async function() {
this.timeout(90_000 + 30_000 * servers.length);
await Promise.all(servers.map((server: MongodSetup) => server.start()));
});
after(async function() {
this.timeout(30_000 * servers.length);
await Promise.all(servers.map((server: MongodSetup) => server.stop()));
});
return servers;
}
function skipIfVersion(test: any, testServerVersion: string, semverCondition: string): void {
// Strip -rc.0, -alpha, etc. from the server version because semver rejects those otherwise.
testServerVersion = testServerVersion.replace(/-.*$/, '');
if (semver.satisfies(testServerVersion, semverCondition)) {
test.skip();
}
}
/**
* Skip tests in the suite if the test server version matches a specific semver
* condition.
*
* describe('...', () => {
* e.g. skipIfServerVersion(testServer, '< 4.4')
* });
*/
export function skipIfServerVersion(server: MongodSetup, semverCondition: string): void {
before(async function() {
skipIfVersion(this, await server.serverVersion(), semverCondition);
});
}
/**
* Skip tests in the suite if the test server is a community server.
*
* describe('...', () => {
* e.g. skipIfCommunityServer(testServer)
* });
*/
export function skipIfCommunityServer(server: MongodSetup): void {
before(async function() {
if (await server.isCommunityServer()) {
this.skip();
}
});
}
/**
* Add the server tarball's bin/ directrory to the PATH for this section.
* This enables using e.g. mongocryptd if available.
*
* describe('...', () => {
* useBinaryPath(testServer)
* });
*/
export function useBinaryPath(server: MongodSetup): void {
let pathBefore: string;
before(async() => {
await server.start();
pathBefore = process.env.PATH ?? '';
const extraPath = server.bindir;
if (extraPath !== null) {
process.env.PATH += path.delimiter + extraPath;
}
});
after(() => {
process.env.PATH = pathBefore;
});
}
/**
* Skip tests in the suite if the test server version
* (configured as environment variable or the currently installed one)
* matches a specific semver version.
*
* If this method cannot find an environment variable or already installed
* mongod, it uses `9999.9999.9999` as version to compare against, since
* `startTestServer` will use the latest available version.
*
* IMPORTANT: As the environment variable might be `4.0.x` it will be converted
* to `4.0.0` to be able to do a semver comparison!
*
* @param semverCondition Semver condition
*/
export function skipIfEnvServerVersion(semverCondition: string): void {
before(async function() {
let testServerVersion = process.env.MONGOSH_SERVER_TEST_VERSION;
if (!testServerVersion) {
try {
testServerVersion = await getInstalledMongodVersion();
} catch(e) {
// no explicitly specified version but also no local mongod installation
testServerVersion = '9999.9999.9999';
}
} else {
testServerVersion = testServerVersion.split('-')[0].split('.')
.map(num => /[0-9]+/.test(num) ? num : '0')
.join('.');
}
skipIfVersion(this, testServerVersion, semverCondition);
});
}