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.ts b/packages/mongodb-runner/src/mongocluster.ts index 20b0a4eb..b5b93959 100644 --- a/packages/mongodb-runner/src/mongocluster.ts +++ b/packages/mongodb-runner/src/mongocluster.ts @@ -110,6 +110,11 @@ export interface CommonOptions { */ tlsAddClientKey?: boolean; + /** + * Whether to require an API version for commands. + */ + requireApiVersion?: number; + /** * Topology of the cluster. */ @@ -488,6 +493,7 @@ export class MongoCluster extends EventEmitter { ...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; @@ -528,6 +534,7 @@ export class MongoCluster extends EventEmitter { } await cluster.addAuthIfNeeded(); + await cluster.addRequireApiVersionIfNeeded(options); return cluster; } @@ -536,6 +543,32 @@ export class MongoCluster extends EventEmitter { yield* this.shards; } + 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 Promise.all( + [...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. diff --git a/packages/mongodb-runner/src/mongoserver.ts b/packages/mongodb-runner/src/mongoserver.ts index 7e7fab1c..1abb6a0b 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'; /** @@ -286,9 +287,13 @@ 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. + if (!options.args?.includes('--keyFile')) { + const buildInfoError = await srv._populateBuildInfo('insert-new'); + if (buildInfoError) { + debug('failed to get buildInfo', buildInfoError); + } } } catch (err) { await srv.close(); @@ -301,24 +306,77 @@ export class MongoServer extends EventEmitter { async updateDefaultConnectionOptions( options: Partial, ): Promise { + // Assume we need these new options to connect. + this.defaultConnectionOptions = { + ...this.defaultConnectionOptions, + ...options, + }; + + // If there is no auth in the connection options, do an immediate metadata refresh and return. let buildInfoError: Error | null = null; + if (!options.auth) { + buildInfoError = await this._populateBuildInfo('restore-check'); + if (buildInfoError) { + debug( + 'failed to refresh buildInfo when updating connection options', + buildInfoError, + options, + ); + throw buildInfoError; + } + return; + } + + debug('Waiting for authorization on', this.port); + + // Wait until we can get connectionStatus. + let supportsAuth = false; + let error: unknown = null; for (let attempts = 0; attempts < 10; attempts++) { - buildInfoError = await this._populateBuildInfo('restore-check', { - ...options, - }); - if (!buildInfoError) break; + error = null; + try { + supportsAuth = await this.withClient(async (client) => { + const status = await client + .db('admin') + .command({ connectionStatus: 1 }); + if (status.authInfo.authenticatedUsers.length > 0) { + return true; + } + // The server is most likely an arbiter, which does not support + // authenticated users but does support getting the buildInfo. + debug('Server does not support authorization', this.port); + this.buildInfo = await client.db('admin').command({ buildInfo: 1 }); + return false; + }); + } catch (e) { + error = e; + await sleep(2 ** attempts * 10); + } + if (error === null) { + break; + } + } + + if (error !== null) { + throw error; + } + + if (!supportsAuth) { + return; + } + + const mode = this.hasInsertedMetadataCollEntry + ? 'restore-check' + : 'insert-new'; + buildInfoError = await this._populateBuildInfo(mode); + if (buildInfoError) { debug( - 'failed to get buildInfo when setting new options', + 'failed to refresh buildInfo when updating connection options', buildInfoError, options, - this.connectionString, ); + throw buildInfoError; } - if (buildInfoError) throw buildInfoError; - this.defaultConnectionOptions = { - ...this.defaultConnectionOptions, - ...options, - }; } async close(): Promise {