Skip to content
Merged
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
34 changes: 34 additions & 0 deletions packages/mongodb-runner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ Options:
--docker Docker image name to run server instances under [string]
--id ID to save the cluster metadata under [string]
--all for `stop`: stop all clusters [boolean]
--json for `start` and `ls`: print machine-readable JSON
on stdout instead of plain text [boolean]
--debug Enable debug output [boolean]
--help Show help [boolean]
```
Expand Down Expand Up @@ -109,6 +111,38 @@ You can specify a config file for the CLI using `--config`:
$ npx @mongodb-js/mongodb-runner start --config <path/to/config.json>
```

## Machine-readable output

By default `start` prints diagnostics to stderr and the bare connection string
to stdout, and `ls` prints one `id: uri` line per running instance. Pass
`--json` to get structured output on stdout instead (diagnostics still go to
stderr, and are not part of the JSON). The JSON is printed on a single line so
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"}
```

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`
object with the allocated `ports` and `services`. Capture it to a file rather
than scraping stdout:

```sh
$ npx mongodb-runner start -t replset --json > cluster.json
```

`ls --json` prints a JSON array of the running instances, with the serialized
cluster data for each one. Credentials (keyfile contents, user passwords, and
TLS certificate passwords) are redacted:

```sh
$ npx mongodb-runner ls --json
[{"id":"2b56a4d3-...","connectionString":"mongodb://localhost:27017/","filepath":"/home/user/.mongodb/runner2/m-2b56a4d3-....json","topology":"standalone","servers":[{"_id":"...","port":27017}],...}]
```

## DSC clusters

mongodb-runner can launch clusters that use DSC, backed by an SLS storage
Expand Down
20 changes: 16 additions & 4 deletions packages/mongodb-runner/docs/disaggregated-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,22 @@ aws ecr get-login-password --region us-east-1 | \
# --downloadUrl=https://.../dsc-mongod.tgz
```

This prints the connection string once the cluster is up. The first run pulls
all SLS images, which can take several minutes (`--debug` shows the
progress). `mongodb-runner stop --id=...` (printed by `start`) tears down the
mongod processes and the compose project together.
This prints the connection string once the cluster is up. To get the allocated
SLS service ports/URIs as structured output, add `--json` and capture stdout to
a file instead of scraping it:

```bash
mongodb-runner start -t replset \
--slsCompose=$MONGO_REPO/buildscripts/modules/atlas/sls-multicell-docker-compose.yml \
--binDir=/path/to/dsc-mongod/bin \
--json > cluster.json
```

The JSON object contains `id` and `connectionString` plus an `sls` field with
the `ports` and `services` (the same info printed to stderr without `--json`).
The first run pulls all SLS images, which can take several minutes (`--debug`
shows the progress). `mongodb-runner stop --id=...` (printed by `start`) tears
down the mongod processes and the compose project together.

The same works for `-t standalone` and `-t sharded`. A DSC-capable
`mongod` is required (see Prerequisites) — with a stock binary, startup fails
Expand Down
73 changes: 73 additions & 0 deletions packages/mongodb-runner/src/cli.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,77 @@ describe('cli', function () {

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

it('emits structured output for start with --json', async function () {
const stdout = await runCli([
'start',
'--topology',
'standalone',
'--json',
]);

const parsed = JSON.parse(stdout) as {
id: string;
connectionString: string;
};

expect(stdout.trim(), 'JSON output should be a single line').to.not.contain(
'\n',
);
expect(parsed.id, 'result should carry the cluster id').to.be.a('string');
expect(
parsed.connectionString,
'result should carry the connection string',
).to.match(/^mongodb:\/\//);

const client = new MongoClient(parsed.connectionString);
const result = await client.db('admin').command({ ping: 1 });
await client.close();
expect(result.ok, 'reported connection string should be usable').to.eq(1);

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

it('emits structured output for ls with --json', async function () {
const startStdout = await runCli([
'start',
'--topology',
'standalone',
'--json',
]);
const started = JSON.parse(startStdout);

const lsStdout = await runCli(['ls', '--json']);
expect(
lsStdout.trim(),
'ls --json output should be a single line',
).to.not.contain('\n');
let parsed: any;
expect(() => {
parsed = JSON.parse(lsStdout);
}, 'ls --json stdout must be parseable as JSON').to.not.throw();

expect(parsed, 'ls --json should yield an array').to.be.an('array');
const entry = parsed.find((e: any) => e.id === started.id);
expect(entry, 'the started cluster should be listed').to.not.equal(
undefined,
);
expect(
entry.connectionString,
'listed connection string should match the one start reported',
).to.equal(started.connectionString);

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

it('emits an empty JSON array for ls --json with no instances', async function () {
const emptyDir = path.join(tmpDir, 'empty-runner');
await fs.mkdir(emptyDir, { recursive: true });

const stdout = await runCli(['ls', '--json', '--runnerDir', emptyDir]);

expect(JSON.parse(stdout)).to.deep.equal([]);
});
});
43 changes: 40 additions & 3 deletions packages/mongodb-runner/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import os from 'os';
import path from 'path';
import createDebug from 'debug';
import * as utilities from './index';
import { redactSerializedCluster } from './mongocluster';
import { ConnectionString } from 'mongodb-connection-string-url';
import type { MongoClientOptions } from 'mongodb';

Expand Down Expand Up @@ -95,6 +96,12 @@ import type { MongoClientOptions } from 'mongodb';
})
.option('debug', { type: 'boolean', describe: 'Enable debug output' })
.option('verbose', { type: 'boolean', describe: 'Enable verbose output' })
.option('json', {
type: 'boolean',
default: false,
describe:
'For `start` and `ls`: print machine-readable JSON on stdout instead of plain text',
Comment thread
nbbeeken marked this conversation as resolved.
})
.command('start', 'Start a MongoDB instance')
.command('stop', 'Stop a MongoDB instance')
.command('prune', 'Clean up metadata for any dead MongoDB instances')
Expand Down Expand Up @@ -170,7 +177,29 @@ import type { MongoClientOptions } from 'mongodb';
? `--runnerDir=${argv.runnerDir}`
: ''),
);
console.log(cs.toString());
if (argv.json) {
const result = {
id,
connectionString: cluster.connectionString,
...(cluster.oidcIssuer
? {
oidcIssuer: cluster.oidcIssuer,
connectionStringWithOidc: cs.toString(),
}
: {}),
...(disaggregatedStorage && 'sls' in disaggregatedStorage
? {
sls: {
ports: disaggregatedStorage.sls.ports,
services: disaggregatedStorage.sls.services,
},
}
: {}),
};
console.log(JSON.stringify(result));
} else {
console.log(cs.toString());
}
cluster.unref();
}

Expand All @@ -182,8 +211,16 @@ import type { MongoClientOptions } from 'mongodb';
}

async function ls() {
for await (const { id, connectionString } of utilities.instances(argv)) {
console.log(`${id}: ${connectionString}`);
if (argv.json) {
const entries: unknown[] = [];
for await (const { serialized, ...rest } of utilities.instances(argv)) {
entries.push({ ...rest, ...redactSerializedCluster(serialized) });
}
console.log(JSON.stringify(entries));
} else {
for await (const { id, connectionString } of utilities.instances(argv)) {
console.log(`${id}: ${connectionString}`);
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/mongodb-runner/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export {
MongoServer,
type MongoServerEvents,
MongoServerOptions,
SerializedServerProperties as MongoServerSerializedProperties,
} from './mongoserver';
export {
MongoCluster,
Expand All @@ -14,6 +15,7 @@ export {
ShardedOptions as MongoClusterShardedOptions,
type ShardDescriptor,
type DisaggregatedStorageOptions,
SerializedClusterProperties as MongoClusterSerializedProperties,
} from './mongocluster';
export {
createSLSMultiCellEnvironment,
Expand Down
98 changes: 97 additions & 1 deletion packages/mongodb-runner/src/mongocluster.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { expect } from 'chai';
import { MongoCluster } from './mongocluster';
import {
MongoCluster,
redactSerializedCluster,
type SerializedClusterProperties,
} from './mongocluster';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
Expand Down Expand Up @@ -918,4 +922,96 @@ describe('MongoCluster', function () {
expect(client.serverApi?.version).to.eq('1');
});
});

describe('serialization', function () {
it('can serialize metadata written before the options field existed', async function () {
// Older metadata has no `options` key; serializing it again must not throw.
const cluster = await MongoCluster.deserialize({
topology: 'standalone',
servers: [],
shards: [],
defaultConnectionOptions: {},
users: [],
});
expect(() => cluster.serialize()).to.not.throw();
});
});

describe('redactSerializedCluster', function () {
it('removes credentials recursively without mutating the input', function () {
const serialized = {
topology: 'replset',
replSetName: 'repl0',
servers: [
{
_id: 'a',
keyFileContents: 'server-keyfile-secret',
commandline: ['mongod', '--auth'],
},
{
_id: 'b',
commandline: [
'mongod',
'--tlsCertificateKeyFilePassword',
'arg-secret',
],
},
{
_id: 'c',
commandline: [
'mongod',
'--tlsCertificateKeyFilePassword=eq-secret',
],
},
],
shards: [
{
topology: 'replset',
servers: [{ _id: 'd', keyFileContents: 'shard-keyfile-secret' }],
shards: [],
defaultConnectionOptions: { auth: { password: 'nested-password' } },
users: [
{ username: 'nested', password: 'nested-password', roles: [] },
],
},
],
defaultConnectionOptions: {
tlsCertificateKeyFilePassword: 'tls-secret',
},
users: [{ username: 'top', password: 'top-password', roles: [] }],
options: {
topology: 'replset',
args: ['--auth'],
users: [{ username: 'opt', password: 'option-password', roles: [] }],
},
} as unknown as SerializedClusterProperties;

const redacted = redactSerializedCluster(serialized);

const json = JSON.stringify(redacted);
for (const secret of [
'server-keyfile-secret',
'shard-keyfile-secret',
'arg-secret',
'eq-secret',
'nested-password',
'top-password',
'option-password',
'tls-secret',
]) {
expect(json, `should not contain ${secret}`).to.not.contain(secret);
}

// Non-secret data is preserved.
expect(redacted.servers[0]._id).to.eq('a');
expect(redacted.users[0].username).to.eq('top');
expect(redacted.options?.args).to.deep.eq(['--auth']);

// The stored metadata is left untouched.
expect(serialized.servers[0].keyFileContents).to.eq(
'server-keyfile-secret',
);
expect(serialized.users[0].password).to.eq('top-password');
});
});
});
Loading
Loading