Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/mongodb-runner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,18 @@ it can be captured and parsed without whitespace splitting:

```sh
$ npx mongodb-runner start -t replset --json
{"id":"2b56a4d3-...","connectionString":"mongodb://localhost:27017,localhost:27018/?replicaSet=repl0"}
{"id":"2b56a4d3-...","connectionString":"mongodb://localhost:27017,localhost:27018/?replicaSet=repl0","topology":"replset","replSetName":"repl0","servers":[...],"shards":[]}
```

The result includes the serialized cluster data, the same fields that
`ls --json` reports for this id (`topology`, `servers`, `shards`, and related
fields), so no separate `ls` invocation is needed to learn each server's host,
port, and role. Unlike `ls --json`, it does not include `filepath`, since the
metadata file location is only meaningful later. As with `ls --json`,
credentials in the serialized cluster fields are redacted. The
connection-string fields may still contain authentication credentials, so
treat the output as sensitive.

For OIDC clusters the result also contains `oidcIssuer` and
`connectionStringWithOidc` (the connection string with
`authMechanism=MONGODB-OIDC` appended). For DSC clusters it contains an `sls`
Expand Down
145 changes: 145 additions & 0 deletions packages/mongodb-runner/src/cli.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,13 @@ describe('cli', function () {
const parsed = JSON.parse(stdout) as {
id: string;
connectionString: string;
topology: string;
servers: Array<{
host: string;
port: number;
dbPath: string;
isMongos: boolean;
}>;
};

expect(stdout.trim(), 'JSON output should be a single line').to.not.contain(
Expand All @@ -228,6 +235,22 @@ describe('cli', function () {
parsed.connectionString,
'result should carry the connection string',
).to.match(/^mongodb:\/\//);
expect(parsed.topology, 'result should carry the topology').to.equal(
'standalone',
);
expect(parsed.servers, 'result should carry the full server list')
.to.be.an('array')
.with.lengthOf(1);
expect(parsed.servers[0].port, 'server entry should carry a port').to.be.a(
'number',
);
expect(parsed.servers[0].host, 'server entry should carry a host').to.be.a(
'string',
);
expect(
parsed.servers[0].dbPath,
'server entry should carry a dbPath',
).to.be.a('string');

const client = new MongoClient(parsed.connectionString);
const result = await client.db('admin').command({ ping: 1 });
Expand Down Expand Up @@ -266,6 +289,18 @@ describe('cli', function () {
entry.connectionString,
'listed connection string should match the one start reported',
).to.equal(started.connectionString);
expect(
entry.topology,
'listed topology should match the one start reported',
).to.equal(started.topology);
expect(
entry.servers,
'listed servers should match the ones start reported',
).to.deep.equal(started.servers);
expect(
entry.shards,
'listed shards should match the ones start reported',
).to.deep.equal(started.shards);

await runCli(['stop', '--all']);
await runCli(['prune']);
Expand All @@ -289,6 +324,116 @@ describe('cli', function () {
expect(stdout.trim()).to.equal('');
});

it('emits the full sharded topology for start with --json', async function () {
this.timeout(300_000);
const startStdout = await runCli([
'start',
'--topology',
'sharded',
'--shards',
'2',
'--json',
]);
const started = JSON.parse(startStdout) as {
id: string;
topology: string;
servers: Array<{ isMongos: boolean }>;
shards: Array<{ servers: Array<{ isConfigSvr: boolean }> }>;
};

expect(started.topology).to.equal('sharded');
expect(started.servers, 'top-level servers should include the mongos')
.to.be.an('array')
.with.lengthOf(1);
expect(
started.servers[0].isMongos,
'the top-level server should be a mongos',
).to.be.true;
expect(
started.shards,
'the config server cluster and both shards should be reported',
)
.to.be.an('array')
.with.lengthOf(3);
expect(
started.shards[0].servers.every((s: any) => s.isConfigSvr),
'the first entry should be the config server cluster',
).to.be.true;
expect(
started.shards.slice(1).every((shard: any) => shard.servers.length > 0),
'each shard should report its own server list',
).to.be.true;
expect(
started.shards
.slice(1)
.every((shard: any) => shard.servers.every((s: any) => !s.isConfigSvr)),
'the remaining entries should be shard servers',
).to.be.true;

const lsStdout = await runCli(['ls', '--json']);
const entry = JSON.parse(lsStdout).find((e: any) => e.id === started.id);
expect(
entry.shards,
'listed shards should match the ones start reported',
).to.deep.equal(started.shards);

await runCli(['stop', '--all']);
await runCli(['prune']);
});

it('redacts credentials in start --json output', async function () {
const runnerDir = path.join(tmpDir, 'runner-redaction');
const keyFile = path.join(tmpDir, 'redaction-keyfile');
const keyFileContents = 'redactionkeyfilesecret';
await fs.writeFile(keyFile, keyFileContents, { mode: 0o600 });
const configFile = path.join(tmpDir, 'redaction-config.json');
await fs.writeFile(
configFile,
JSON.stringify({
topology: 'replset',
runnerDir,
args: ['--keyFile', keyFile],
rsMembers: [{ priority: 1 }, { priority: 1 }, { priority: 1 }],
users: [
{
username: 'redactionUser',
password: 'redaction-password',
roles: ['root'],
},
],
}),
);

const stdout = await runCli(['start', '--config', configFile, '--json']);

// The connection string intentionally embeds the user's credentials (the
// same string `ls --json` reports), so only the serialized cluster data
// is checked for redaction here.
const parsed = JSON.parse(stdout);
const serializedJson = JSON.stringify({
...parsed,
connectionString: undefined,
});
expect(
serializedJson,
'the user password must not leak into the serialized data',
).to.not.contain('redaction-password');
expect(
serializedJson,
'the keyfile contents must not leak into the serialized data',
).to.not.contain(keyFileContents);
expect(
serializedJson,
'redacted keyfile fields must not appear',
).to.not.contain('keyFileContents');
expect(
parsed.users?.[0],
'users should still be reported, without the password',
).to.deep.equal({ username: 'redactionUser', roles: ['root'] });

await runCli(['stop', '--all', '--runnerDir', runnerDir]);
});

it('rejects a --slsDir with no path', async function () {
const stderr = await runCliExpectingFailure(['start', '--slsDir']);
expect(stderr, 'the error should say a path is required').to.include(
Expand Down
9 changes: 7 additions & 2 deletions packages/mongodb-runner/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ import type { MongoClientOptions } from 'mongodb';
console.error(` ${serviceName}: ${addr}`);
}
}
const { cluster, id } = await utilities.start(
const { cluster, id, serialized, connectionString } = await utilities.start(
{ ...argv, disaggregatedStorage },
args,
);
Expand Down Expand Up @@ -195,8 +195,13 @@ import type { MongoClientOptions } from 'mongodb';
);
if (argv.json) {
const result = {
// Same serialized cluster fields that `ls --json` reports for this
// id, read back from the exact data written to the metadata file, so
// callers do not need a separate `ls` invocation to learn the cluster
// topology. Explicit keys below win in case of future name clashes.
...redactSerializedCluster(serialized),
id,
connectionString: cluster.connectionString,
connectionString,
...(cluster.oidcIssuer
? {
oidcIssuer: cluster.oidcIssuer,
Expand Down
2 changes: 1 addition & 1 deletion packages/mongodb-runner/src/runner-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export async function start(

cluster.unref();

return { cluster, id };
return { cluster, id, serialized, connectionString };
}

export async function* instances(argv: {
Expand Down
Loading