diff --git a/packages/i18n/src/locales/en_US.ts b/packages/i18n/src/locales/en_US.ts index d29e8475c4..b5edd8074b 100644 --- a/packages/i18n/src/locales/en_US.ts +++ b/packages/i18n/src/locales/en_US.ts @@ -1399,6 +1399,11 @@ const translations: Catalog = { description: 'Reconfigures an existing replica set, overwriting the existing replica set configuration.', example: 'rs.reconfig()' }, + reconfigForPSASet: { + link: 'https://docs.mongodb.com/manual/reference/method/rs.reconfigForPSASet', + description: 'Reconfigures an existing replica set, overwriting the existing replica set configuration, if the reconfiguration is a transition from a Primary-Arbiter to a Primary-Secondary-Arbiter set.', + example: 'rs.reconfigForPSASet(indexOfNewMemberInMembersArray, config)' + }, conf: { link: 'https://docs.mongodb.com/manual/reference/method/rs.conf', description: 'Calls replSetConfig', diff --git a/packages/shell-api/src/replica-set.spec.ts b/packages/shell-api/src/replica-set.spec.ts index 27d2c8f554..f61a5b4b77 100644 --- a/packages/shell-api/src/replica-set.spec.ts +++ b/packages/shell-api/src/replica-set.spec.ts @@ -1,9 +1,10 @@ import { CommonErrors, MongoshInvalidInputError, MongoshRuntimeError } from '@mongosh/errors'; -import { bson, FindCursor as ServiceProviderCursor, ServiceProvider } from '@mongosh/service-provider-core'; +import { bson, FindCursor as ServiceProviderCursor, ServiceProvider, Document } from '@mongosh/service-provider-core'; import chai, { expect } from 'chai'; import { EventEmitter } from 'events'; +import semver from 'semver'; import sinonChai from 'sinon-chai'; -import { StubbedInstance, stubInterface } from 'ts-sinon'; +import sinon, { StubbedInstance, stubInterface } from 'ts-sinon'; import { ensureMaster } from '../../../testing/helpers'; import { MongodSetup, skipIfServerVersion, startTestCluster } from '../../../testing/integration-testing-hooks'; import { CliServiceProvider } from '../../service-provider-server'; @@ -16,10 +17,14 @@ import { } from './enums'; import { signatures, toShellResult } from './index'; import Mongo from './mongo'; -import ReplicaSet from './replica-set'; -import ShellInternalState from './shell-internal-state'; +import ReplicaSet, { ReplSetMemberConfig, ReplSetConfig } from './replica-set'; +import ShellInternalState, { EvaluationListener } from './shell-internal-state'; chai.use(sinonChai); +function deepClone(value: T): T { + return JSON.parse(JSON.stringify(value)); +} + describe('ReplicaSet', () => { describe('help', () => { const apiClass: any = new ReplicaSet({} as any); @@ -58,17 +63,12 @@ describe('ReplicaSet', () => { describe('unit', () => { let mongo: Mongo; let serviceProvider: StubbedInstance; + let evaluationListener: StubbedInstance; let rs: ReplicaSet; let bus: StubbedInstance; let internalState: ShellInternalState; let db: Database; - const findResolvesWith = (expectedResult): void => { - const findCursor = stubInterface(); - findCursor.tryNext.resolves(expectedResult); - serviceProvider.find.returns(findCursor); - }; - beforeEach(() => { bus = stubInterface(); serviceProvider = stubInterface(); @@ -76,7 +76,9 @@ describe('ReplicaSet', () => { serviceProvider.bsonLibrary = bson; serviceProvider.runCommand.resolves({ ok: 1 }); serviceProvider.runCommandWithCheck.resolves({ ok: 1 }); + evaluationListener = stubInterface(); internalState = new ShellInternalState(serviceProvider, bus); + internalState.setEvaluationListener(evaluationListener); mongo = new Mongo(internalState, undefined, undefined, undefined, serviceProvider); db = new Database(mongo, 'testdb'); rs = new ReplicaSet(db); @@ -294,10 +296,11 @@ describe('ReplicaSet', () => { it('calls serviceProvider.runCommandWithCheck with no arb and string hostport', async() => { const configDoc = { version: 1, members: [{ _id: 0 }, { _id: 1 }] }; const hostname = 'localhost:27017'; - findResolvesWith(configDoc); - serviceProvider.countDocuments.resolves(1); const expectedResult = { ok: 1 }; - serviceProvider.runCommandWithCheck.resolves(expectedResult); + serviceProvider.runCommandWithCheck.callsFake(async(db, command) => { + if (command.replSetGetConfig) {return { ok: 1, config: configDoc };} + return expectedResult; + }); const result = await rs.add(hostname); expect(serviceProvider.runCommandWithCheck).to.have.been.calledWith( @@ -318,10 +321,12 @@ describe('ReplicaSet', () => { it('calls serviceProvider.runCommandWithCheck with arb and string hostport', async() => { const configDoc = { version: 1, members: [{ _id: 0 }, { _id: 1 }] }; const hostname = 'localhost:27017'; - findResolvesWith(configDoc); serviceProvider.countDocuments.resolves(1); const expectedResult = { ok: 1 }; - serviceProvider.runCommandWithCheck.resolves(expectedResult); + serviceProvider.runCommandWithCheck.callsFake(async(db, command) => { + if (command.replSetGetConfig) {return { ok: 1, config: configDoc };} + return expectedResult; + }); const result = await rs.add(hostname, true); expect(serviceProvider.runCommandWithCheck).to.have.been.calledWith( @@ -345,10 +350,12 @@ describe('ReplicaSet', () => { const hostname = { host: 'localhost:27017' }; - findResolvesWith(configDoc); serviceProvider.countDocuments.resolves(1); const expectedResult = { ok: 1 }; - serviceProvider.runCommandWithCheck.resolves(expectedResult); + serviceProvider.runCommandWithCheck.callsFake(async(db, command) => { + if (command.replSetGetConfig) {return { ok: 1, config: configDoc };} + return expectedResult; + }); const result = await rs.add(hostname); expect(serviceProvider.runCommandWithCheck).to.have.been.calledWith( @@ -372,10 +379,12 @@ describe('ReplicaSet', () => { const hostname = { host: 'localhost:27017', _id: 10 }; - findResolvesWith(configDoc); serviceProvider.countDocuments.resolves(1); const expectedResult = { ok: 1 }; - serviceProvider.runCommandWithCheck.resolves(expectedResult); + serviceProvider.runCommandWithCheck.callsFake(async(db, command) => { + if (command.replSetGetConfig) {return { ok: 1, config: configDoc };} + return expectedResult; + }); const result = await rs.add(hostname); expect(serviceProvider.runCommandWithCheck).to.have.been.calledWith( @@ -397,37 +406,26 @@ describe('ReplicaSet', () => { it('throws with arb and object hostport', async() => { const configDoc = { version: 1, members: [{ _id: 0 }, { _id: 1 }] }; const hostname = { host: 'localhost:27017' }; - findResolvesWith(configDoc); serviceProvider.countDocuments.resolves(1); const expectedResult = { ok: 1 }; - serviceProvider.runCommandWithCheck.resolves(expectedResult); + serviceProvider.runCommandWithCheck.callsFake(async(db, command) => { + if (command.replSetGetConfig) {return { ok: 1, config: configDoc };} + return expectedResult; + }); const error = await rs.add(hostname, true).catch(e => e); expect(error).to.be.instanceOf(MongoshInvalidInputError); expect(error.code).to.equal(CommonErrors.InvalidArgument); }); - it('throws if local.system.replset.count <= 1', async() => { - const configDoc = { version: 1, members: [{ _id: 0 }, { _id: 1 }] }; - const hostname = { host: 'localhost:27017' }; - findResolvesWith(configDoc); - serviceProvider.countDocuments.resolves(2); - const error = await rs.add(hostname, true).catch(e => e); - expect(error).to.be.instanceOf(MongoshRuntimeError); - expect(error.code).to.equal(CommonErrors.CommandFailed); - }); it('throws if local.system.replset.findOne has no docs', async() => { const hostname = { host: 'localhost:27017' }; - findResolvesWith(null); - serviceProvider.countDocuments.resolves(1); + serviceProvider.runCommandWithCheck.resolves({ ok: 1 }); const error = await rs.add(hostname, true).catch(e => e); expect(error).to.be.instanceOf(MongoshRuntimeError); expect(error.code).to.equal(CommonErrors.CommandFailed); }); it('throws if serviceProvider.runCommandWithCheck rejects', async() => { - const configDoc = { version: 1, members: [{ _id: 0 }, { _id: 1 }] }; - findResolvesWith(configDoc); - serviceProvider.countDocuments.resolves(1); const expectedError = new Error(); serviceProvider.runCommandWithCheck.rejects(expectedError); const catchedError = await rs.add('hostname') @@ -439,10 +437,11 @@ describe('ReplicaSet', () => { it('calls serviceProvider.runCommandWithCheck', async() => { const configDoc = { version: 1, members: [{ _id: 0, host: 'localhost:0' }, { _id: 1, host: 'localhost:1' }] }; const hostname = 'localhost:0'; - findResolvesWith(configDoc); - serviceProvider.countDocuments.resolves(1); const expectedResult = { ok: 1 }; - serviceProvider.runCommandWithCheck.resolves(expectedResult); + serviceProvider.runCommandWithCheck.callsFake(async(db, command) => { + if (command.replSetGetConfig) {return { ok: 1, config: configDoc };} + return expectedResult; + }); const result = await rs.remove(hostname); expect(serviceProvider.runCommandWithCheck).to.have.been.calledWith( @@ -463,25 +462,7 @@ describe('ReplicaSet', () => { const error = await rs.remove(hostname).catch(e => e); expect(error.name).to.equal('MongoshInvalidInputError'); }); - it('throws if local.system.replset.count <= 1', async() => { - const configDoc = { version: 1, members: [{ _id: 0, host: 'localhost:0' }, { _id: 1, host: 'lcoalhost:1' }] }; - findResolvesWith(configDoc); - serviceProvider.countDocuments.resolves(0); - const error = await rs.remove('').catch(e => e); - expect(error).to.be.instanceOf(MongoshRuntimeError); - expect(error.code).to.equal(CommonErrors.CommandFailed); - }); - it('throws if local.system.replset.count <= 1', async() => { - findResolvesWith(null); - serviceProvider.countDocuments.resolves(1); - const error = await rs.remove('').catch(e => e); - expect(error).to.be.instanceOf(MongoshRuntimeError); - expect(error.code).to.equal(CommonErrors.CommandFailed); - }); it('throws if serviceProvider.runCommandWithCheck rejects', async() => { - const configDoc = { version: 1, members: [{ _id: 0, host: 'localhost:0' }, { _id: 1, host: 'localhost:1' }] }; - findResolvesWith(configDoc); - serviceProvider.countDocuments.resolves(1); const expectedError = new Error(); serviceProvider.runCommandWithCheck.rejects(expectedError); const catchedError = await rs.remove('localhost:1') @@ -490,8 +471,7 @@ describe('ReplicaSet', () => { }); it('throws if hostname not in members', async() => { const configDoc = { version: 1, members: [{ _id: 0, host: 'localhost:0' }, { _id: 1, host: 'lcoalhost:1' }] }; - findResolvesWith(configDoc); - serviceProvider.countDocuments.resolves(1); + serviceProvider.runCommandWithCheck.resolves({ ok: 1, config: configDoc }); const catchedError = await rs.remove('localhost:2') .catch(e => e); expect(catchedError).to.be.instanceOf(MongoshInvalidInputError); @@ -601,9 +581,168 @@ describe('ReplicaSet', () => { expect(catchedError).to.equal(expectedError); }); }); + describe('reconfigForPSASet', () => { + let secondary: ReplSetMemberConfig; + let config: Partial; + let oldConfig: ReplSetConfig; + let reconfigCalls: ReplSetConfig[]; + let reconfigResults: Document[]; + let sleepStub: any; + + beforeEach(() => { + sleepStub = sinon.stub(); + internalState.shellApi.sleep = sleepStub; + secondary = { + _id: 2, host: 'secondary.mongodb.net', priority: 1, votes: 1 + }; + oldConfig = { + _id: 'replSet', + members: [ + { _id: 0, host: 'primary.monogdb.net', priority: 1, votes: 1 }, + { _id: 1, host: 'arbiter.monogdb.net', priority: 1, votes: 0, arbiterOnly: true } + ], + protocolVersion: 1, + version: 1 + }; + config = deepClone(oldConfig); + config.members.push(secondary); + reconfigResults = [ { ok: 1 }, { ok: 1 } ]; + reconfigCalls = []; + + serviceProvider.runCommandWithCheck.callsFake(async(db: string, cmd: Document) => { + if (cmd.replSetGetConfig) { + return { config: oldConfig }; + } + if (cmd.replSetReconfig) { + const result = reconfigResults.shift(); + reconfigCalls.push(deepClone(cmd.replSetReconfig)); + if (result.ok) { + oldConfig = deepClone(cmd.replSetReconfig); + return result; + } + throw new Error(`Reconfig failed: ${JSON.stringify(result)}`); + } + }); + }); + + it('fails if index is incorrect', async() => { + try { + await rs.reconfigForPSASet(3, config); + expect.fail('missed exception'); + } catch (err) { + expect(err.message).to.equal('[COMMON-10001] Node at index 3 does not exist in the new config'); + } + }); + + it('fails if secondary.votes != 1', async() => { + secondary.votes = 0; + try { + await rs.reconfigForPSASet(2, config); + expect.fail('missed exception'); + } catch (err) { + expect(err.message).to.equal('[COMMON-10001] Node at index 2 must have { votes: 1 } in the new config (actual: { votes: 0 })'); + } + }); + + it('fails if old note had votes', async() => { + oldConfig.members.push(secondary); + try { + await rs.reconfigForPSASet(2, config); + expect.fail('missed exception'); + } catch (err) { + expect(err.message).to.equal('[COMMON-10001] Node at index 2 must have { votes: 0 } in the old config (actual: { votes: 1 })'); + } + }); + + it('warns if there is an existing member with the same host', async() => { + oldConfig.members.push(deepClone(secondary)); + secondary._id = 3; + await rs.reconfigForPSASet(2, config); + expect(evaluationListener.onPrint).to.have.been.calledWith([ + await toShellResult( + 'Warning: Node at index 2 has { host: "secondary.mongodb.net" }, ' + + 'which is also present in the old config, but with a different _id field.') + ]); + }); + + it('skips the second reconfig if priority is 0', async() => { + secondary.priority = 0; + await rs.reconfigForPSASet(2, config); + expect(reconfigCalls).to.deep.equal([ + { ...config, version: 2 } + ]); + expect(evaluationListener.onPrint).to.have.been.calledWith([ + await toShellResult('Running first reconfig to give member at index 2 { votes: 1, priority: 0 }') + ]); + expect(evaluationListener.onPrint).to.have.been.calledWith([ + await toShellResult('No second reconfig necessary because .priority = 0') + ]); + }); + + it('does two reconfigs if priority is 1', async() => { + const origConfig = deepClone(config); + await rs.reconfigForPSASet(2, config); + expect(reconfigCalls).to.deep.equal([ + { ...origConfig, members: [ config.members[0], config.members[1], { ...secondary, priority: 0 } ], version: 2 }, + { ...origConfig, version: 3 } + ]); + expect(evaluationListener.onPrint).to.have.been.calledWith([ + await toShellResult('Running first reconfig to give member at index 2 { votes: 1, priority: 0 }') + ]); + expect(evaluationListener.onPrint).to.have.been.calledWith([ + await toShellResult('Running second reconfig to give member at index 2 { priority: 1 }') + ]); + }); + + it('does three reconfigs the second one fails', async() => { + reconfigResults = [{ ok: 1 }, { ok: 0 }, { ok: 1 }]; + const origConfig = deepClone(config); + await rs.reconfigForPSASet(2, config); + expect(reconfigCalls).to.deep.equal([ + { ...origConfig, members: [ config.members[0], config.members[1], { ...secondary, priority: 0 } ], version: 2 }, + { ...origConfig, version: 3 }, + { ...origConfig, version: 3 } + ]); + expect(evaluationListener.onPrint).to.have.been.calledWith([ + await toShellResult('Running first reconfig to give member at index 2 { votes: 1, priority: 0 }') + ]); + expect(evaluationListener.onPrint).to.have.been.calledWith([ + await toShellResult('Running second reconfig to give member at index 2 { priority: 1 }') + ]); + expect(sleepStub).to.have.been.calledWith(1000); + }); + + it('gives up after a number of attempts', async() => { + reconfigResults = [...Array(20).keys()].map((i) => ({ ok: i === 0 ? 1 : 0 })); + try { + await rs.reconfigForPSASet(2, config); + expect.fail('missed exception'); + } catch (err) { + expect(err.message).to.equal('Reconfig failed: {"ok":0}'); + } + expect(evaluationListener.onPrint).to.have.been.calledWith([ + await toShellResult('Running first reconfig to give member at index 2 { votes: 1, priority: 0 }') + ]); + expect(evaluationListener.onPrint).to.have.been.calledWith([ + await toShellResult('Running second reconfig to give member at index 2 { priority: 1 }') + ]); + expect(evaluationListener.onPrint).to.have.been.calledWith([ + await toShellResult('Second reconfig did not succeed yet, starting new attempt...') + ]); + expect(evaluationListener.onPrint).to.have.been.calledWith([ + await toShellResult('Second reconfig did not succeed, giving up') + ]); + const totalSleepLength = sleepStub.getCalls() + .map(({ firstArg }) => firstArg) + .reduce((x, y) => x + y, 0); + // Expect to spend about a minute sleeping here. + expect(totalSleepLength).to.be.closeTo(60_000, 5_000); + expect(reconfigCalls).to.have.lengthOf(1 + 12); + }); + }); }); - describe('integration', () => { + describe('integration (standard setup)', () => { const replId = 'rs0'; const [ srv0, srv1, srv2, srv3 ] = startTestCluster( @@ -613,12 +752,7 @@ describe('ReplicaSet', () => { ['--single', '--replSet', replId] ); - type ReplSetConfig = { - _id: string; - members: {_id: number, host: string, priority: number}[]; - protocolVersion?: number; - }; - let cfg: ReplSetConfig; + let cfg: Partial; let additionalServer: MongodSetup; let serviceProvider: CliServiceProvider; let internalState; @@ -690,7 +824,7 @@ describe('ReplicaSet', () => { }); describe('reconfig', () => { it('reconfig with one less secondary', async() => { - const newcfg: ReplSetConfig = { + const newcfg: Partial = { _id: replId, members: [ cfg.members[0], cfg.members[1] ] }; @@ -755,4 +889,71 @@ describe('ReplicaSet', () => { }); }); }); + + describe('integration (PA to PSA transition)', () => { + const replId = 'rspsa'; + + const [ srv0, srv1, srv2 ] = startTestCluster( + ['--single', '--replSet', replId], + ['--single', '--replSet', replId], + ['--single', '--replSet', replId] + ); + + let serviceProvider: CliServiceProvider; + + beforeEach(async() => { + serviceProvider = await CliServiceProvider.connect(`${await srv0.connectionString()}?directConnection=true`); + }); + + afterEach(async() => { + return await serviceProvider.close(true); + }); + + it('fails with rs.reconfig but works with rs.reconfigForPSASet', async function() { + this.timeout(100_000); + const [primary, secondary, arbiter] = await Promise.all([ + srv0.hostport(), + srv1.hostport(), + srv2.hostport() + ]); + const cfg = { + _id: replId, + members: [ + { _id: 0, host: primary, priority: 1 } + ] + }; + + const internalState = new ShellInternalState(serviceProvider); + const db = internalState.currentDb; + const rs = new ReplicaSet(db); + + expect((await rs.initiate(cfg)).ok).to.equal(1); + await ensureMaster(rs, 1000, primary); + + if (semver.gte(await db.version(), '4.4.0')) { // setDefaultRWConcern is 4.4+ only + await db.getSiblingDB('admin').runCommand({ + setDefaultRWConcern: 1, + defaultWriteConcern: { w: 'majority' } + }); + } + await rs.addArb(arbiter); + + if (semver.gt(await db.version(), '4.9.0')) { // Exception currently 5.0+ only + try { + await rs.add(secondary); + expect.fail('missed assertion'); + } catch (err) { + expect(err.codeName).to.equal('NewReplicaSetConfigurationIncompatible'); + } + } + + const conf = await rs.conf(); + conf.members.push({ _id: 2, host: secondary, votes: 1, priority: 1 }); + await rs.reconfigForPSASet(2, conf); + + const { members } = await rs.status(); + expect(members).to.have.lengthOf(3); + expect(members.filter(member => member.stateStr === 'PRIMARY')).to.have.lengthOf(1); + }); + }); }); diff --git a/packages/shell-api/src/replica-set.ts b/packages/shell-api/src/replica-set.ts index cf5416bf4a..92a8c6d91e 100644 --- a/packages/shell-api/src/replica-set.ts +++ b/packages/shell-api/src/replica-set.ts @@ -15,6 +15,21 @@ import { CommandResult } from './result'; import { redactCredentials } from '@mongosh/history'; import { Mongo } from '.'; +export type ReplSetMemberConfig = { + _id: number; + host: string; + priority?: number; + votes?: number; + arbiterOnly?: boolean; +}; + +export type ReplSetConfig = { + version: number; + _id: string; + members: ReplSetMemberConfig[]; + protocolVersion: number; +}; + @shellApiClassDefault export default class ReplicaSet extends ShellApiWithMongoClass { _database: Database; @@ -34,32 +49,25 @@ export default class ReplicaSet extends ShellApiWithMongoClass { * @param config */ @returnsPromise - async initiate(config = {}): Promise { + async initiate(config: Partial = {}): Promise { this._emitReplicaSetApiCall('initiate', { config }); return this._database._runAdminCommand({ replSetInitiate: config }); } - /** - * rs.config calls replSetReconfig admin command. - * - * Returns a document that contains the current replica set configuration. - */ - @returnsPromise - async config(): Promise { - this._emitReplicaSetApiCall('config', {}); + async _getConfig(): Promise { try { const result = await this._database._runAdminCommand( { replSetGetConfig: 1 } ); if (result.config === undefined) { - throw new MongoshRuntimeError('Documented returned from command replSetReconfig does not contain \'config\''); + throw new MongoshRuntimeError('Documented returned from command replSetGetConfig does not contain \'config\'', CommonErrors.CommandFailed); } return result.config; } catch (error) { if (error.codeName === 'CommandNotFound') { - const doc = await this._database.getSiblingDB('local').getCollection('system.replset').findOne(); + const doc = await this._database.getSiblingDB('local').getCollection('system.replset').findOne() as ReplSetConfig | null; if (doc === null) { - throw new MongoshRuntimeError('No documents in local.system.replset'); + throw new MongoshRuntimeError('No documents in local.system.replset', CommonErrors.CommandFailed); } return doc; } @@ -67,12 +75,24 @@ export default class ReplicaSet extends ShellApiWithMongoClass { } } + /** + * rs.config calls replSetGetConfig admin command. + * + * Returns a document that contains the current replica set configuration. + */ + @returnsPromise + async config(): Promise { + this._emitReplicaSetApiCall('config', {}); + return this._getConfig(); + } + /** * Alias, conf is documented but config is not */ @returnsPromise - async conf(): Promise { - return this.config(); + async conf(): Promise { + this._emitReplicaSetApiCall('conf', {}); + return this._getConfig(); } /** @@ -82,11 +102,11 @@ export default class ReplicaSet extends ShellApiWithMongoClass { * @param options */ @returnsPromise - async reconfig(config: Document, options = {}): Promise { + async reconfig(config: Partial, options = {}): Promise { assertArgsDefinedType([ config, options ], ['object', [undefined, 'object']], 'ReplicaSet.reconfig'); this._emitReplicaSetApiCall('reconfig', { config, options }); - const conf = await this.conf(); + const conf = await this._getConfig(); config.version = conf.version ? conf.version + 1 : 1; config.protocolVersion ??= conf.protocolVersion; // Needed on mongod 4.0.x @@ -95,6 +115,105 @@ export default class ReplicaSet extends ShellApiWithMongoClass { return this._database._runAdminCommand(cmd); } + /** + * A more involved version specifically for transitioning from a Primary-Arbiter + * to a Primary-Secondary-Arbiter set (PA to PSA for short). + */ + @returnsPromise + // eslint-disable-next-line complexity + async reconfigForPSASet(newMemberIndex: number, config: Partial, options = {}): Promise { + assertArgsDefinedType( + [ newMemberIndex, config, options ], + [ 'number', 'object', [undefined, 'object'] ], + 'ReplicaSet.reconfigForPSASet'); + this._emitReplicaSetApiCall('reconfigForPSASet', { newMemberIndex, config, options }); + const print = (msg: string) => this._internalState.shellApi.print(msg); + const sleep = (duration: number) => this._internalState.shellApi.sleep(duration); + + // First, perform some validation on the combination of newMemberIndex + config. + const newMemberConfig = config.members?.[newMemberIndex]; + if (!newMemberConfig) { + throw new MongoshInvalidInputError( + `Node at index ${newMemberIndex} does not exist in the new config`, + CommonErrors.InvalidArgument + ); + } + if (newMemberConfig.votes !== 1) { + throw new MongoshInvalidInputError( + `Node at index ${newMemberIndex} must have { votes: 1 } in the new config (actual: { votes: ${newMemberConfig.votes} })`, + CommonErrors.InvalidArgument + ); + } + + const oldConfig = await this._getConfig(); + + // Use _id to compare nodes across configs. + const oldMemberConfig = oldConfig.members.find(member => member._id === newMemberConfig._id); + + // If the node doesn't exist in the old config, we are adding it as a new node. Skip validating + // the node in the old config. + if (!oldMemberConfig) { + if (oldConfig.members.find(member => member.host === newMemberConfig.host)) { + await print( + `Warning: Node at index ${newMemberIndex} has { host: "${newMemberConfig.host}" }, ` + + 'which is also present in the old config, but with a different _id field.'); + } + } else if (oldMemberConfig.votes) { + throw new MongoshInvalidInputError( + `Node at index ${newMemberIndex} must have { votes: 0 } in the old config (actual: { votes: ${oldMemberConfig.votes} })`, + CommonErrors.InvalidArgument + ); + } + + // The new config is valid, so start the first reconfig. + const newMemberPriority = newMemberConfig.priority; + await print(`Running first reconfig to give member at index ${newMemberIndex} { votes: 1, priority: 0 }`); + newMemberConfig.votes = 1; + newMemberConfig.priority = 0; + const firstResult = await this.reconfig(config, options); + + if (newMemberPriority === 0) { + await print('No second reconfig necessary because .priority = 0'); + return firstResult; + } + + await print(`Running second reconfig to give member at index ${newMemberIndex} { priority: ${newMemberPriority} }`); + newMemberConfig.priority = newMemberPriority; + + // If the first reconfig added a new node, the second config will not succeed until the + // automatic reconfig to remove the 'newlyAdded' field is completed. Retry the second reconfig + // until it succeeds in that case. + let result: [ 'error', Error ] | [ 'success', Document ] = [ 'success', {} ]; + let sleepInterval = 1000; + for (let i = 0; i < 12; i++) { + try { + if (result[0] === 'error') { + // Do a mild exponential backoff. If it's been a while since the last + // update, also tell the user that we're actually still working on + // the reconfig. + await sleep(sleepInterval); + sleepInterval *= 1.3; + if (sleepInterval > 2500) { + await print('Second reconfig did not succeed yet, starting new attempt...'); + } + } + result = [ 'success', await this.reconfig(config, options) ]; + break; + } catch (err) { + result = [ 'error', err ]; + } + } + + if (result[0] === 'error') { + // If this did not work out, print the attempted command to give the user + // a chance to complete the second reconfig manually. + await print('Second reconfig did not succeed, giving up'); + await print(`Attempted command: rs.reconfig(${JSON.stringify(config, null, ' ')}, ${JSON.stringify(options)})`); + throw result[1]; + } + return result[1]; + } + @returnsPromise async status(): Promise { this._emitReplicaSetApiCall('status', {}); @@ -134,23 +253,16 @@ export default class ReplicaSet extends ShellApiWithMongoClass { } @returnsPromise - async add(hostport: string | Document, arb?: boolean): Promise { + async add(hostport: string | Partial, arb?: boolean): Promise { assertArgsDefinedType([hostport, arb], [['string', 'object'], [undefined, 'boolean']], 'ReplicaSet.add'); this._emitReplicaSetApiCall('add', { hostport, arb }); - const local = this._database.getSiblingDB('local'); - if (await local.getCollection('system.replset').countDocuments({}) !== 1) { - throw new MongoshRuntimeError('local.system.replset has unexpected contents', CommonErrors.CommandFailed); - } - const configDoc = await local.getCollection('system.replset').findOne(); - if (configDoc === undefined || configDoc === null) { - throw new MongoshRuntimeError('no config object retrievable from local.system.replset', CommonErrors.CommandFailed); - } + const configDoc = await this._getConfig(); configDoc.version++; - const max = Math.max(...configDoc.members.map((m: any) => m._id)); - let cfg: any; + const max = Math.max(...configDoc.members.map(m => m._id)); + let cfg: Partial; if (typeof hostport === 'string') { cfg = { _id: max + 1, host: hostport }; if (arb) { @@ -168,7 +280,7 @@ export default class ReplicaSet extends ShellApiWithMongoClass { } } - configDoc.members.push(cfg); + configDoc.members.push(cfg as ReplSetMemberConfig); return this._database._runAdminCommand( { replSetReconfig: configDoc, @@ -186,14 +298,7 @@ export default class ReplicaSet extends ShellApiWithMongoClass { async remove(hostname: string): Promise { assertArgsDefinedType([hostname], ['string'], 'ReplicaSet.remove'); this._emitReplicaSetApiCall('remove', { hostname }); - const local = this._database.getSiblingDB('local'); - if (await local.getCollection('system.replset').countDocuments({}) !== 1) { - throw new MongoshRuntimeError('local.system.replset has unexpected contents', CommonErrors.CommandFailed); - } - const configDoc = await local.getCollection('system.replset').findOne(); - if (configDoc === null || configDoc === undefined) { - throw new MongoshRuntimeError('no config object retrievable from local.system.replset', CommonErrors.CommandFailed); - } + const configDoc = await this._getConfig(); configDoc.version++; for (let i = 0; i < configDoc.members.length; i++) { @@ -227,9 +332,9 @@ export default class ReplicaSet extends ShellApiWithMongoClass { async stepDown(stepdownSecs?: number, catchUpSecs?: number): Promise { assertArgsDefinedType([stepdownSecs, catchUpSecs], [[undefined, 'number'], [undefined, 'number']], 'ReplicaSet.stepDown'); this._emitReplicaSetApiCall('stepDown', { stepdownSecs, catchUpSecs }); - const cmd = { + const cmd: Document = { replSetStepDown: stepdownSecs === undefined ? 60 : stepdownSecs, - } as any; + }; if (catchUpSecs !== undefined) { cmd.secondaryCatchUpPeriodSecs = catchUpSecs; }