diff --git a/packages/mongodb-runner/src/cli.spec.ts b/packages/mongodb-runner/src/cli.spec.ts new file mode 100644 index 00000000..be2b3ff1 --- /dev/null +++ b/packages/mongodb-runner/src/cli.spec.ts @@ -0,0 +1,143 @@ +import { expect } from 'chai'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { promisify } from 'util'; +import { execFile } from 'child_process'; +import type { ExecOptions } from 'child_process'; +import { MongoClient } from 'mongodb'; + +const execFileAsync = promisify(execFile); + +async function runCli( + args: string[], + options: ExecOptions = {}, +): Promise { + const { stdout } = await execFileAsync( + process.execPath, + [path.resolve(__dirname, '..', 'bin', 'runner.js'), ...args], + options, + ); + return stdout; +} + +describe('cli', function () { + this.timeout(process.platform === 'win32' ? 400_000 : 100_000); + let tmpDir = ''; + + before(async function () { + tmpDir = path.join(os.tmpdir(), `runner-cli-tests-${Date.now()}`); + await fs.mkdir(tmpDir, { recursive: true }); + }); + + after(async function () { + await fs.rm(tmpDir, { + force: true, + recursive: true, + maxRetries: 100, + }); + }); + + it('can manage a standalone cluster with command line args', async function () { + // Start the CLI with arguments and capture stdout. + const stdout = await runCli(['start', '--topology', 'standalone']); + + // stdout is JUST the connection string. + const connectionString = stdout.trim(); + expect(connectionString).to.match(/^mongodb:\/\//); + + // Connect to the cluster. + const client = new MongoClient(connectionString); + const result = await client.db('admin').command({ ping: 1 }); + await client.close(); + expect(result.ok).to.eq(1); + + // Exercise the rest of the cli. + const lsStdout = await runCli(['ls']); + expect(lsStdout.includes(connectionString)).to.be.true; + + await runCli(['stop', '--all']); + + await runCli(['prune']); + }); + + it('can execute against a cluster', async function () { + const stdout = await runCli([ + 'exec', + '-t', + 'standalone', + '--', + 'sh', + '-c', + 'echo $MONGODB_URI', + ]); + const connectionString = stdout.trim(); + expect(connectionString).to.match(/^mongodb:\/\//); + }); + + it('can manage a replset cluster with command line args', async function () { + const stdout = await runCli([ + 'start', + '--topology', + 'replset', + '--secondaries', + '2', + '--arbiters', + '1', + '--version', + '8.0.x', + '--', + '--replSet', + 'repl0', + ]); + const connectionString = stdout.trim(); + expect(/repl0/.test(connectionString)).to.be.true; + + const client = new MongoClient(connectionString); + const result = await client.db('admin').command({ ping: 1 }); + await client.close(); + expect(result.ok).to.eq(1); + + await runCli(['stop', '--all']); + }); + + it('can manage a sharded cluster with command line args', async function () { + const stdout = await runCli([ + 'start', + '--topology', + 'sharded', + '--shards', + '2', + '--version', + '7.0.x', + ]); + const connectionString = stdout.trim(); + + const client = new MongoClient(connectionString); + const result = await client.db('admin').command({ ping: 1 }); + await client.close(); + expect(result.ok).to.eq(1); + + await runCli(['stop', '--all']); + }); + + it('can manage a cluster with a config file', async function () { + const configFile = path.resolve( + __dirname, + '..', + 'test', + 'fixtures', + 'config.json', + ); + const stdout = await runCli(['start', '--config', configFile]); + const connectionString = stdout.trim(); + expect(/repl0/.test(connectionString)).to.be.true; + + const client = new MongoClient(connectionString); + const result = await client.db('admin').command({ ping: 1 }); + await client.close(); + expect(result.ok).to.eq(1); + + await runCli(['stop', '--all']); + }); +}); diff --git a/packages/mongodb-runner/src/cli.ts b/packages/mongodb-runner/src/cli.ts index 6e00db37..633eb19a 100644 --- a/packages/mongodb-runner/src/cli.ts +++ b/packages/mongodb-runner/src/cli.ts @@ -90,6 +90,10 @@ import type { MongoClientOptions } from 'mongodb'; .demandCommand(1, 'A command needs to be provided') .help().argv; const [command, ...args] = argv._.map(String); + // Allow args to be provided by the config file. + if (Array.isArray(argv.args)) { + args.push(...argv.args.map(String)); + } if (argv.debug || argv.verbose) { createDebug.enable('mongodb-runner'); } @@ -111,22 +115,29 @@ import type { MongoClientOptions } from 'mongodb'; async function start() { const { cluster, id } = await utilities.start(argv, args); const cs = new ConnectionString(cluster.connectionString); - console.log(`Server started and running at ${cs.toString()}`); + // Only the connection string should print to stdout so it can be captured + // by a calling process. + console.error(`Server started and running at ${cs.toString()}`); if (cluster.oidcIssuer) { cs.typedSearchParams().set( 'authMechanism', 'MONGODB-OIDC', ); - console.log(`OIDC provider started and running at ${cluster.oidcIssuer}`); - console.log(`Server connection string with OIDC auth: ${cs.toString()}`); + console.error( + `OIDC provider started and running at ${cluster.oidcIssuer}`, + ); + console.error( + `Server connection string with OIDC auth: ${cs.toString()}`, + ); } - console.log('Run the following command to stop the instance:'); - console.log( + console.error('Run the following command to stop the instance:'); + console.error( `${argv.$0} stop --id=${id}` + (argv.runnerDir !== defaultRunnerDir ? `--runnerDir=${argv.runnerDir}` : ''), ); + console.log(cs.toString()); cluster.unref(); } diff --git a/packages/mongodb-runner/src/mongocluster.spec.ts b/packages/mongodb-runner/src/mongocluster.spec.ts index edcb20b0..d6dc855b 100644 --- a/packages/mongodb-runner/src/mongocluster.spec.ts +++ b/packages/mongodb-runner/src/mongocluster.spec.ts @@ -409,6 +409,65 @@ describe('MongoCluster', function () { await cluster.close(); }); + it('can serialize and deserialize sharded cluster with keyfile and auth (no enableTestCommands)', async function () { + const keyFile = path.join(tmpDir, 'keyFile2'); + await fs.writeFile(keyFile, 'secret', { mode: 0o400 }); + cluster = await MongoCluster.start({ + version: '8.x', + topology: 'sharded', + tmpDir, + secondaries: 0, + users: [ + { + username: 'testuser', + password: 'testpass', + roles: [{ role: 'readWriteAnyDatabase', db: 'admin' }], + }, + ], + args: ['--keyFile', keyFile], + }); + cluster = await MongoCluster.deserialize(cluster.serialize()); + await cluster.withClient(async (client) => { + expect( + (await client.db('admin').command({ connectionStatus: 1 })).authInfo + .authenticatedUsers, + ).to.deep.equal([{ user: 'testuser', db: 'admin' }]); + }); + await cluster.close(); + }); + + it('can serialize and deserialize sharded cluster with keyfile and auth (enableTestCommands=true)', async function () { + const keyFile = path.join(tmpDir, 'keyFile3'); + await fs.writeFile(keyFile, 'secret', { mode: 0o400 }); + cluster = await MongoCluster.start({ + version: '8.x', + topology: 'sharded', + tmpDir, + secondaries: 0, + users: [ + { + username: 'testuser', + password: 'testpass', + roles: [{ role: 'readWriteAnyDatabase', db: 'admin' }], + }, + ], + args: ['--keyFile', keyFile, '--setParameter', 'enableTestCommands=true'], + }); + cluster = await MongoCluster.deserialize(cluster.serialize()); + const doc = await cluster.withClient( + async (client) => { + expect( + (await client.db('admin').command({ connectionStatus: 1 })).authInfo + .authenticatedUsers, + ).to.deep.equal([{ user: '__system', db: 'local' }]); + return await client.db('config').collection('mongodbrunner').findOne(); + }, + { auth: { username: '__system', password: 'secret' } }, + ); + expect(doc?._id).to.be.a('string'); + await cluster.close(); + }); + it('can let callers listen for server log events', async function () { cluster = await MongoCluster.start({ version: '8.x', @@ -630,4 +689,53 @@ describe('MongoCluster', function () { { user: 'testuser', db: 'admin' }, ]); }); + + it('can use a keyFile', async function () { + const keyFile = path.join(tmpDir, 'keyFile'); + await fs.writeFile(keyFile, 'secret', { mode: 0o400 }); + cluster = await MongoCluster.start({ + version: '8.x', + topology: 'replset', + tmpDir, + secondaries: 1, + arbiters: 1, + args: ['--keyFile', keyFile], + users: [ + { + username: 'testuser', + password: 'testpass', + roles: [ + { role: 'userAdminAnyDatabase', db: 'admin' }, + { role: 'clusterAdmin', db: 'admin' }, + ], + }, + ], + }); + expect(cluster.connectionString).to.be.a('string'); + expect(cluster.serverVersion).to.match(/^8\./); + expect(cluster.connectionString).to.include('testuser:testpass@'); + cluster = await MongoCluster.deserialize(cluster.serialize()); + expect(cluster.connectionString).to.include('testuser:testpass@'); + }); + + it('can support requireApiVersion', async function () { + cluster = await MongoCluster.start({ + version: '8.x', + topology: 'sharded', + tmpDir, + secondaries: 1, + shards: 1, + requireApiVersion: 1, + args: ['--setParameter', 'enableTestCommands=1'], + }); + expect(cluster.connectionString).to.be.a('string'); + expect(cluster.serverVersion).to.match(/^8\./); + await cluster.withClient((client) => { + expect(client.serverApi?.version).to.eq('1'); + }); + cluster = await MongoCluster.deserialize(cluster.serialize()); + await cluster.withClient((client) => { + expect(client.serverApi?.version).to.eq('1'); + }); + }); }); diff --git a/packages/mongodb-runner/src/mongocluster.ts b/packages/mongodb-runner/src/mongocluster.ts index 20b0a4eb..533ec26b 100644 --- a/packages/mongodb-runner/src/mongocluster.ts +++ b/packages/mongodb-runner/src/mongocluster.ts @@ -18,6 +18,7 @@ import { jsonClone, debugVerbose, makeConnectionString, + safePromiseAll, } from './util'; import { OIDCMockProviderProcess } from './oidc'; import { EventEmitter } from 'events'; @@ -110,6 +111,11 @@ export interface CommonOptions { */ tlsAddClientKey?: boolean; + /** + * Whether to require an API version for commands. + */ + requireApiVersion?: number; + /** * Topology of the cluster. */ @@ -322,10 +328,10 @@ export class MongoCluster extends EventEmitter { cluster.replSetName = serialized.replSetName; cluster.defaultConnectionOptions = serialized.defaultConnectionOptions; cluster.users = serialized.users; - cluster.servers = await Promise.all( + cluster.servers = await safePromiseAll( serialized.servers.map((srv: any) => MongoServer.deserialize(srv)), ); - cluster.shards = await Promise.all( + cluster.shards = await safePromiseAll( serialized.shards.map((shard: any) => MongoCluster.deserialize(shard)), ); cluster.oidcMockProviderProcess = serialized.oidcMockProviderProcess @@ -424,12 +430,13 @@ export class MongoCluster extends EventEmitter { ); assert.notStrictEqual(primaryIndex, -1); - const nodes = await Promise.all( + const nodes = await safePromiseAll( rsMembers.map(async (member) => { return [ await MongoServer.start({ ...options, args: member.args, + isArbiter: member.arbiterOnly ?? false, binary: 'mongod', }), member, @@ -481,13 +488,14 @@ export class MongoCluster extends EventEmitter { } else if (options.topology === 'sharded') { const { shards, mongosArgs } = processShardOptions(options); debug('starting config server and shard servers', shards); - const allShards = await Promise.all( + const allShards = await safePromiseAll( shards.map(async (s) => { const isConfig = s.args?.includes('--configsvr'); const cluster = await MongoCluster.start({ ...options, ...s, topology: 'replset', + requireApiVersion: undefined, users: isConfig ? undefined : options.users, // users go on the mongos/config server only for the config set }); return [cluster, isConfig] as const; @@ -499,7 +507,7 @@ export class MongoCluster extends EventEmitter { .map(([shard]) => shard); cluster.shards.push(configsvr, ...shardsvrs); - const mongosServers: MongoServer[] = await Promise.all( + const mongosServers: MongoServer[] = await safePromiseAll( mongosArgs.map(async (args) => { debug('starting mongos'); return await MongoServer.start({ @@ -528,6 +536,17 @@ export class MongoCluster extends EventEmitter { } await cluster.addAuthIfNeeded(); + await cluster.addRequireApiVersionIfNeeded(options); + try { + await cluster.assertAllServersHaveInsertedLocalMetadata(); + } catch (err) { + // Allow connection errors if automatic tls client key addition is disabled + // since that option implies to users that they are taking responsibility for + // ensuring that the correct runner instance is used for managing processes. + // Similarly, adding TLS client keys automatically in Docker may not be + // reliably possible due to filesystem path differences. + if (options.tlsAddClientKey !== false && !options.docker) throw err; + } return cluster; } @@ -536,6 +555,45 @@ export class MongoCluster extends EventEmitter { yield* this.shards; } + private *allServers(): Iterable { + for (const child of this.servers) yield child; + for (const shard of this.shards) yield* shard.allServers(); + } + + async assertAllServersHaveInsertedLocalMetadata(): Promise { + await safePromiseAll( + [...this.allServers()].map( + async (server) => await server.assertHasInsertedLocalMetadata(), + ), + ); + } + + async addRequireApiVersionIfNeeded({ + ...options + }: MongoClusterOptions): Promise { + // Set up requireApiVersion if requested. + if (options.requireApiVersion === undefined) { + return; + } + if (options.topology === 'replset') { + throw new Error( + 'requireApiVersion is not supported for replica sets, see SERVER-97010', + ); + } + await safePromiseAll( + [...this.servers].map( + async (child) => + await child.withClient(async (client) => { + const admin = client.db('admin'); + await admin.command({ setParameter: 1, requireApiVersion: true }); + }), + ), + ); + await this.updateDefaultConnectionOptions({ + serverApi: String(options.requireApiVersion) as '1', + }); + } + async addAuthIfNeeded(): Promise { if (!this.users?.length) return; // Sleep to give time for a possible replset election to settle. @@ -545,7 +603,14 @@ export class MongoCluster extends EventEmitter { for (const user of this.users) { const { username, password, ...rest } = user; debug('adding new user', { username, ...rest }, this.connectionString); - await admin.command({ createUser: username, pwd: password, ...rest }); + await admin.command({ + createUser: username, + pwd: password, + // User management commands can only use w:1 or w:majority + // https://github.com/mongodb/mongo/blob/4b65e1c663042d6c2e879ab20ba4b3c22439997a/src/mongo/db/global_catalog/sharding_catalog_client_impl.cpp#L1112-L1113 + writeConcern: { w: 'majority', j: true, wtimeout: 0 }, + ...rest, + }); } }); await this.updateDefaultConnectionOptions({ @@ -556,7 +621,7 @@ export class MongoCluster extends EventEmitter { async updateDefaultConnectionOptions( options: Partial, ): Promise { - await Promise.all( + await safePromiseAll( [...this.children()].map(async (child) => child.updateDefaultConnectionOptions(options), ), @@ -568,7 +633,7 @@ export class MongoCluster extends EventEmitter { } async close(): Promise { - await Promise.all( + await safePromiseAll( [...this.children(), this.oidcMockProviderProcess].map((closable) => closable?.close(), ), @@ -607,7 +672,12 @@ export class MongoCluster extends EventEmitter { case 'standalone': return {}; case 'replset': - return { writeConcern: { w: this.servers.length, j: true } }; + return { + writeConcern: { + w: this.servers.filter((s) => !s.isArbiter).length, + j: true, + }, + }; case 'sharded': return { writeConcern: { diff --git a/packages/mongodb-runner/src/mongoserver.ts b/packages/mongodb-runner/src/mongoserver.ts index 7e7fab1c..a19da349 100644 --- a/packages/mongodb-runner/src/mongoserver.ts +++ b/packages/mongodb-runner/src/mongoserver.ts @@ -20,6 +20,7 @@ import { debugVerbose, jsonClone, makeConnectionString, + sleep, } from './util'; /** @@ -40,6 +41,14 @@ export interface MongoServerOptions { docker?: string | string[]; /** Internal options for the MongoDB client used by this server instance. */ internalClientOptions?: Partial; + /** Internal option -- if this is an arbiter, it does not understand user auth */ + isArbiter?: boolean; + /** Internal option -- if this is a mongos instance */ + isMongos?: boolean; + /** Internal option -- if this is a configsvr instance */ + isConfigSvr?: boolean; + /** Internal option -- if keyfile auth is used, this will be its contents */ + keyFileContents?: string; } interface SerializedServerProperties { @@ -50,12 +59,26 @@ interface SerializedServerProperties { defaultConnectionOptions?: Partial; startTime: string; hasInsertedMetadataCollEntry: boolean; + isArbiter?: boolean; + isMongos?: boolean; + isConfigSvr?: boolean; + keyFileContents?: string; } export interface MongoServerEvents { mongoLog: [LogEntry]; } +function getKeyFileOption(args?: string[] | undefined): string | undefined { + if (!args) return undefined; + const splitArgIndex = args.indexOf('--keyFile'); + if (splitArgIndex !== -1) { + return args[splitArgIndex + 1]; + } + const arg = args.find((a) => a.startsWith('--keyFile=')); + return arg?.split('=')[1]; +} + export class MongoServer extends EventEmitter { public uuid: string = uuid(); private buildInfo?: Document; @@ -66,6 +89,10 @@ export class MongoServer extends EventEmitter { private closing = false; private startTime = new Date().toISOString(); private hasInsertedMetadataCollEntry = false; + public isArbiter = false; + public isMongos = false; + private isConfigSvr = false; + private keyFileContents?: string; private defaultConnectionOptions?: Partial; get id(): string { @@ -86,6 +113,10 @@ export class MongoServer extends EventEmitter { startTime: this.startTime, hasInsertedMetadataCollEntry: this.hasInsertedMetadataCollEntry, defaultConnectionOptions: jsonClone(this.defaultConnectionOptions ?? {}), + isArbiter: this.isArbiter, + isMongos: this.isMongos, + isConfigSvr: this.isConfigSvr, + keyFileContents: this.keyFileContents, }; } @@ -97,6 +128,10 @@ export class MongoServer extends EventEmitter { srv.port = serialized.port; srv.defaultConnectionOptions = serialized.defaultConnectionOptions; srv.closing = !!(await srv._populateBuildInfo('restore-check')); + srv.isArbiter = !!serialized.isArbiter; + srv.isMongos = !!serialized.isMongos; + srv.isConfigSvr = !!serialized.isConfigSvr; + srv.keyFileContents = serialized.keyFileContents; if (!srv.closing) { srv.pid = serialized.pid; srv.dbPath = serialized.dbPath; @@ -166,6 +201,18 @@ export class MongoServer extends EventEmitter { }: MongoServerOptions): Promise { const srv = new MongoServer(); srv.defaultConnectionOptions = { ...options.internalClientOptions }; + srv.isArbiter = !!options.isArbiter; + srv.isMongos = options.binary === 'mongos'; + srv.isConfigSvr = !!options.args?.includes('--configsvr'); + const keyFilePath = getKeyFileOption(options.args); + if (keyFilePath) { + srv.keyFileContents = await fs.readFile(keyFilePath, 'utf8'); + debug('read keyFile contents for server', { + keyFilePath, + uuid: srv.uuid, + }); + } + if (!options.docker) { const dbPath = path.join(options.tmpDir, `db-${srv.uuid}`); await fs.mkdir(dbPath, { recursive: true }); @@ -286,9 +333,15 @@ export class MongoServer extends EventEmitter { logEntryStream.resume(); srv.port = port; - const buildInfoError = await srv._populateBuildInfo('insert-new'); - if (buildInfoError) { - debug('failed to get buildInfo', buildInfoError); + // If a keyFile is present, we cannot read or write on the server until + // a user is added to the primary. This information will be populated + // as part of the cluster's startup process when authenticating, + // enforced by its `assertAllServersHaveInsertedLocalMetadata()` check. + if (!keyFilePath) { + const buildInfoError = await srv._populateBuildInfo('insert-new'); + if (buildInfoError) { + debug('failed to get buildInfo', buildInfoError); + } } } catch (err) { await srv.close(); @@ -301,20 +354,25 @@ export class MongoServer extends EventEmitter { async updateDefaultConnectionOptions( options: Partial, ): Promise { - let buildInfoError: Error | null = null; - for (let attempts = 0; attempts < 10; attempts++) { - buildInfoError = await this._populateBuildInfo('restore-check', { - ...options, - }); - if (!buildInfoError) break; - debug( - 'failed to get buildInfo when setting new options', - buildInfoError, - options, - this.connectionString, - ); + const start = Date.now(); + let error: unknown; + for (let attempts = 0; attempts < 20; attempts++) { + try { + this.buildInfo = await this.withClient(async (client) => { + return await client.db('admin').command({ buildInfo: 1 }); + }, options); + error = undefined; + break; + } catch (err) { + error = err; + } + await sleep(Math.min(1000, 2 ** attempts * 10)); } - if (buildInfoError) throw buildInfoError; + debug('updated default connection options', { + error, + Δt: Date.now() - start, + }); + if (error) throw error; this.defaultConnectionOptions = { ...this.defaultConnectionOptions, ...options, @@ -354,6 +412,17 @@ export class MongoServer extends EventEmitter { mode: 'insert-new' | 'restore-check', ): Promise { const hello = await client.db('admin').command({ hello: 1 }); + const { arbiterOnly } = hello; + if (arbiterOnly === this.isArbiter) { + debug('skipping metadata check for arbiter'); + return; + } + if (this.isArbiter) { + throw new Error( + 'Arbiter flag mismatch -- server should be arbiter but hello indicates it is not', + ); + } + const isMongoS = hello.msg === 'isdbgrid'; const insertedInfo = pick(this.serialize(), [ '_id', @@ -370,7 +439,9 @@ export class MongoServer extends EventEmitter { // mongos hosts require a bit of special treatment because they do not have // local storage of their own, so we store the metadata in the config database, // which may be accessed by multiple mongos instances. - debug('ensuring metadata collection entry', insertedInfo, { isMongoS }); + debug('ensuring metadata collection entry', insertedInfo, { + isMongoS, + }); if (mode === 'insert-new') { const existingEntry = await runnerColl.findOne(); if (!isMongoS && existingEntry) { @@ -412,6 +483,20 @@ export class MongoServer extends EventEmitter { try { // directConnection + retryWrites let us write to `local` db on secondaries clientOpts = { retryWrites: false, ...clientOpts }; + if ( + this.keyFileContents && + (clientOpts.auth || this.defaultConnectionOptions?.auth) + ) { + clientOpts.auth = { + username: '__system', + password: this.keyFileContents, + }; + clientOpts.authMechanism = 'SCRAM-SHA-256'; + // If enableTestCommands=true is set, we can use `admin` as the auth db on mongos + // instances, otherwise we won't be able to insert at all + // https://github.com/mongodb/mongo/blob/3faba053373cff4ff90ffce64114a595b9a70226/src/mongo/db/auth/sasl_mechanism_registry.cpp#L102-L108 + clientOpts.authSource = this.isMongos ? 'admin' : 'local'; + } this.buildInfo = await this.withClient(async (client) => { // Insert the metadata entry, except if we're a freshly started mongos // (which does not have its own storage to persist) @@ -419,7 +504,11 @@ export class MongoServer extends EventEmitter { return await client.db('admin').command({ buildInfo: 1 }); }, clientOpts); } catch (err) { - debug('failed to get buildInfo, treating as closed server', err); + debug( + 'failed to get buildInfo, treating as closed server', + err, + new Error().stack, + ); return err as Error; } debug( @@ -442,11 +531,23 @@ export class MongoServer extends EventEmitter { fn: Fn, clientOptions: MongoClientOptions = {}, ): Promise> { - const client = await MongoClient.connect(this.connectionString, { + clientOptions = { directConnection: true, ...this.defaultConnectionOptions, ...clientOptions, - }); + }; + if ( + this.isArbiter && + clientOptions.auth && + clientOptions.auth.username !== '__system' + ) { + // Arbiters do not understand user authentication + clientOptions.auth = undefined; + } + const client = await MongoClient.connect( + this.connectionString, + clientOptions, + ); try { return await fn(client); } finally { @@ -465,4 +566,27 @@ export class MongoServer extends EventEmitter { (this.childProcess?.stdout as any)?.unref(); (this.childProcess?.stderr as any)?.unref(); } + + async assertHasInsertedLocalMetadata(): Promise { + if (!this.hasInsertedMetadataCollEntry) { + debug('populating metadata collection entry after initial setup'); + const err = await this._populateBuildInfo('insert-new'); + if (err && !this.isMongos && !this.isConfigSvr) throw err; + } + if (!this.buildInfo) { + throw new Error( + `Server buildInfo is not populated ${JSON.stringify(this.serialize())}`, + ); + } + if ( + !this.hasInsertedMetadataCollEntry && + !this.isArbiter && + !this.isMongos && + !this.isConfigSvr + ) { + throw new Error( + `Server has not inserted metadata collection entry ${JSON.stringify(this.serialize())}`, + ); + } + } } diff --git a/packages/mongodb-runner/src/tls-helpers.ts b/packages/mongodb-runner/src/tls-helpers.ts index dd19985c..e04a251f 100644 --- a/packages/mongodb-runner/src/tls-helpers.ts +++ b/packages/mongodb-runner/src/tls-helpers.ts @@ -1,6 +1,6 @@ import * as x509 from '@peculiar/x509'; import { webcrypto } from 'crypto'; -import { uuid } from './util'; +import { safePromiseAll, uuid } from './util'; import path from 'path'; import { writeFile, readFile } from 'fs/promises'; import type { MongoClientOptions } from 'mongodb'; @@ -18,12 +18,15 @@ export async function handleTLSClientKeyOptions({ args: [...args] = [], tmpDir, internalClientOptions = {}, -}: TLSClientOptions): Promise> { + docker, +}: TLSClientOptions & { docker?: unknown }): Promise< + Partial +> { const existingTLSCAOptionIndex = args.findIndex((arg) => - arg.match(/^--tls(Cluster)?CAFile(=|$)/), + arg.match(/^--(tls|ssl)(Cluster)?CAFile(=|$)/), ); - if (tlsAddClientKey === false) return {}; + if (tlsAddClientKey === false || docker) return {}; if (tlsAddClientKey !== true && existingTLSCAOptionIndex === -1) return {}; if (tlsAddClientKey !== true && internalClientOptions.tlsCertificateKeyFile) return {}; @@ -62,7 +65,7 @@ export async function handleTLSClientKeyOptions({ const clientPEM = path.join(tmpDir, `mongodb-runner-client-${id}.pem`); const caPEM = path.join(tmpDir, `mongodb-runner-ca-${id}.pem`); - await Promise.all([ + await safePromiseAll([ (async () => { await writeFile( clientPEM, diff --git a/packages/mongodb-runner/src/util.ts b/packages/mongodb-runner/src/util.ts index fcb1065a..eaafe9c1 100644 --- a/packages/mongodb-runner/src/util.ts +++ b/packages/mongodb-runner/src/util.ts @@ -32,6 +32,31 @@ export async function parallelForEach( return await Promise.allSettled(result); } +/** + * A version of `Promise.all` that waits for all Promises to settle, + * and if any are rejected, throws an `AggregateError` with all errors. + * + * This has the benefit of allowing all Promises to complete, rather than + * failing fast on the first rejection (and potentially leaving more Promises + * in an unhandled rejection state). + */ +export async function safePromiseAll( + promises: (Promise | T)[], +): Promise { + const results = await Promise.allSettled(promises); + const rejected = results.filter( + (r): r is PromiseRejectedResult => r.status === 'rejected', + ); + if (rejected.length) { + if (rejected.length === 1) throw rejected[0].reason; + throw new AggregateError( + [rejected.map((r) => r.reason)], + `${rejected.length} errors: ${rejected.map((r) => r.reason).join(', ')}`, + ); + } + return results.map((r) => (r as PromiseFulfilledResult).value); +} + export function pick( obj: T, keys: K[], diff --git a/packages/mongodb-runner/test/fixtures/config.json b/packages/mongodb-runner/test/fixtures/config.json new file mode 100644 index 00000000..5235578a --- /dev/null +++ b/packages/mongodb-runner/test/fixtures/config.json @@ -0,0 +1,38 @@ +{ + "topology": "replset", + "args": ["--replSet", "repl0"], + "rsMembers": [ + { + "args": [ + "--oplogSize", + "500", + "--setParameter", + "enableTestCommands=true" + ], + "tags": { + "ordinal": "one", + "dc": "ny" + }, + "priority": 1 + }, + { + "args": [ + "--oplogSize", + "500", + "--setParameter", + "enableTestCommands=true" + ], + "tags": { + "ordinal": "two", + "dc": "pa" + }, + "priority": 1 + }, + { + "args": ["--setParameter", "enableTestCommands=true"], + "tags": {}, + "priority": 0, + "arbiterOnly": true + } + ] +} diff --git a/packages/mongodb-runner/tsconfig.json b/packages/mongodb-runner/tsconfig.json index f029e4a0..16c4cb0f 100644 --- a/packages/mongodb-runner/tsconfig.json +++ b/packages/mongodb-runner/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@mongodb-js/tsconfig-devtools/tsconfig.common.json", "compilerOptions": { - "target": "es2020", - "lib": ["es2020"] + "target": "es2021", + "lib": ["es2021"] } }