diff --git a/.prettierignore b/.prettierignore index fe7b3b3b..707d7e2c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,3 @@ resources/cidrs.json packages/mongodb-schema/examples/fanclub.json +packages/mongodb-runner/test/fixtures/sls/malformed/manifest.json diff --git a/packages/mongodb-runner/.prettierignore b/packages/mongodb-runner/.prettierignore index 4d28df66..7ce446d2 100644 --- a/packages/mongodb-runner/.prettierignore +++ b/packages/mongodb-runner/.prettierignore @@ -1,3 +1,4 @@ .nyc_output dist coverage +test/fixtures/sls/malformed diff --git a/packages/mongodb-runner/README.md b/packages/mongodb-runner/README.md index 6740f4e9..1ce23c5d 100644 --- a/packages/mongodb-runner/README.md +++ b/packages/mongodb-runner/README.md @@ -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] ``` @@ -109,6 +111,43 @@ You can specify a config file for the CLI using `--config`: $ npx @mongodb-js/mongodb-runner start --config ``` +## 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): + +```sh +$ npx mongodb-runner start -t replset --json +{ + "id": "2b56a4d3-...", + "connectionString": "mongodb://localhost:27017,localhost:27018/?replicaSet=repl0" +} +``` + +For OIDC clusters the result also carries `oidcIssuer` and +`connectionStringWithOidc` (the connection string with +`authMechanism=MONGODB-OIDC` appended). For DSC clusters it carries 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: + +```sh +$ npx mongodb-runner ls --json +[ + { + "id": "2b56a4d3-...", + "connectionString": "mongodb://localhost:27017/" + } +] +``` + ## DSC clusters mongodb-runner can launch clusters that use DSC, backed by an SLS storage diff --git a/packages/mongodb-runner/docs/disaggregated-storage.md b/packages/mongodb-runner/docs/disaggregated-storage.md index 5a0f24e8..eacd603b 100644 --- a/packages/mongodb-runner/docs/disaggregated-storage.md +++ b/packages/mongodb-runner/docs/disaggregated-storage.md @@ -29,13 +29,35 @@ override. - Docker with `docker compose` v2. - Access to the SLS container images. The default repository is a private ECR - registry, so log in first: + registry. mongodb-runner authenticates to it automatically before starting + the compose project, which requires the `aws` CLI on your `PATH` and AWS + credentials in the usual places. Pass `--slsSkipEcrLogin` to disable this if + you have already authenticated another way. + + To authenticate by hand instead: ```bash - aws ecr get-login-password --region us-east-1 | \ - docker login --username AWS --password-stdin 664315256653.dkr.ecr.us-east-1.amazonaws.com + aws ecr get-authorization-token \ + --region us-east-1 \ + --registry-ids 664315256653 \ + --query 'authorizationData[0].authorizationToken' \ + --output text | + base64 -d | cut -d: -f2- | + docker login --username AWS --password-stdin 664315256653.dkr.ecr.us-east-1.amazonaws.com ``` + Note that `aws ecr get-login-password`, which is the more commonly documented + form, issues a token scoped to _your own_ registry. If your AWS profile lives + outside account `664315256653` that token is for the wrong account and + `docker login` rejects it with a bare `status: 400 Bad Request`. Hence the + explicit `--registry-ids`. + + Authenticating successfully is not sufficient to pull: minting a token only + requires `ecr:GetAuthorizationToken` in your own account, while pulling + requires `ecr:BatchGetImage` granted by a resource policy on the repositories + in `664315256653`. If login succeeds but pulls fail, that policy is what you + are missing. + - A `mongod` build that understands the `disaggregatedStorageConfig` server parameter. Stock community/enterprise release binaries do **not** — you need a build of the server with the atlas module. Provide it either as: @@ -48,38 +70,45 @@ override. server checkout. Files it references (`slsbackup.proto`, `flags-state.json`) are resolved relative to it, and the services/ports are parsed from it, so any version of the file works as-is. -- The SLS image tag to use, typically the `pinned_sls_commit` from - `buildscripts/modules/atlas/manifest.json` in the mongodb server repository. +- The image tag is read automatically from the `pinned_sls_commit` in + `manifest.json` sitting next to the compose file (the server repository's + `buildscripts/modules/atlas/manifest.json`). Pass `--slsImageTag` to + override it. ## Quick start -Given the compose file and image tag, everything else (compose environment +Given the compose file, everything else (image tag, compose environment variables, readiness polling, per-shard log creation, and the `disaggregatedStorageConfig` server parameter) is generated automatically. Full sequence, assuming a mongodb server checkout at `$MONGO_REPO`: ```bash -# 1. Log in to the SLS image registry -aws ecr get-login-password --region us-east-1 | \ - docker login --username AWS --password-stdin 664315256653.dkr.ecr.us-east-1.amazonaws.com - -# 2. Look up the pinned SLS image tag -SLS_IMAGE_TAG=$(python3 -c "import json; print(json.load(open('$MONGO_REPO/buildscripts/modules/atlas/manifest.json'))['pinned_sls_commit'])") - -# 3. Start a 2-node replica set backed by SLS +# The image tag is read from the manifest.json next to the compose file; pass +# --slsImageTag to override it. Logs in to ECR automatically. @mongodb-js/mongodb-runner start -t replset \ --slsCompose=$MONGO_REPO/buildscripts/modules/atlas/sls-multicell-docker-compose.yml \ - --slsImageTag=$SLS_IMAGE_TAG \ --binDir=/path/to/dsc-mongod/bin \ --debug # or, instead of --binDir: # --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 carries `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 @@ -209,7 +238,8 @@ from the server codebase and returns: | `ports` | The allocated host port per service, e.g. `ports['crs-cell1-0']` | | `services` | Host `addr`/`uri` per service, e.g. `services['cms-cell1-0'].uri` | -Required options: `composeFile`, `imageTag`. Optional: `imageRepo`, +Required options: `composeFile`. Optional: `imageTag` (defaults to the +`pinned_sls_commit` from the manifest next to the compose file), `imageRepo`, `thirdPartyImageRepo`, `testDataId` (container label for test attribution), `hostInternalIP`. The service list is parsed from the compose file's `ports:` mappings (`parseSLSComposeServices`), so it adapts to whatever @@ -223,13 +253,13 @@ server parameter for one shard; options: `logId`, `cellMetadataService`, ## CLI use -For an SLS project, `--slsCompose` + `--slsImageTag` handle everything (see -Quick start): +For an SLS project, `--slsCompose` handles everything (see Quick start); +`--slsImageTag` is optional and overrides the tag read from the manifest: ```bash @mongodb-js/mongodb-runner start -t replset \ --slsCompose=/path/to/sls-multicell-docker-compose.yml \ - --slsImageTag= --binDir=... + --binDir=... ``` Custom (non-SLS) storage backends are only supported through the programmatic diff --git a/packages/mongodb-runner/src/cli.spec.ts b/packages/mongodb-runner/src/cli.spec.ts index be2b3ff1..90917cc5 100644 --- a/packages/mongodb-runner/src/cli.spec.ts +++ b/packages/mongodb-runner/src/cli.spec.ts @@ -140,4 +140,61 @@ 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', + ]); + + let parsed: any; + expect(() => { + parsed = JSON.parse(stdout); + }, 'stdout must be parseable as JSON, with diagnostics on stderr').to.not.throw(); + + 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']); + 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']); + }); }); diff --git a/packages/mongodb-runner/src/cli.ts b/packages/mongodb-runner/src/cli.ts index 164ed3cf..02067dbe 100644 --- a/packages/mongodb-runner/src/cli.ts +++ b/packages/mongodb-runner/src/cli.ts @@ -87,12 +87,23 @@ import type { MongoClientOptions } from 'mongodb'; type: 'string', describe: 'Path to an SLS multi-cell docker-compose.yml; launches the SLS DSC project and configures mongod to use it (requires a DSC-capable mongod via --binDir or --downloadUrl)', - implies: 'slsImageTag', }) .option('slsImageTag', { type: 'string', describe: - 'SLS docker image tag to use with --slsCompose (e.g. the pinned_sls_commit from the server repo manifest)', + 'SLS docker image tag to use with --slsCompose (defaults to the pinned_sls_commit from the manifest.json next to the compose file)', + }) + .option('slsSkipEcrLogin', { + type: 'boolean', + default: false, + describe: + 'Skip authenticating to the SLS image registry (use if you have already run docker login)', + }) + .option('json', { + type: 'boolean', + default: false, + describe: + 'For `start` and `ls`: print machine-readable JSON on stdout instead of plain text', }) .option('debug', { type: 'boolean', describe: 'Enable debug output' }) .option('verbose', { type: 'boolean', describe: 'Enable verbose output' }) @@ -133,7 +144,8 @@ import type { MongoClientOptions } from 'mongodb'; const disaggregatedStorage = argv.slsCompose ? await utilities.createSLSDisaggregatedStorageOptions({ composeFile: argv.slsCompose, - imageTag: argv.slsImageTag!, + imageTag: argv.slsImageTag, + ecrLogin: !argv.slsSkipEcrLogin, }) : undefined; if (disaggregatedStorage && 'sls' in disaggregatedStorage) { @@ -171,7 +183,26 @@ import type { MongoClientOptions } from 'mongodb'; ? `--runnerDir=${argv.runnerDir}` : ''), ); - console.log(cs.toString()); + if (argv.json) { + const result: Record = { + __proto__: null, + id, + connectionString: cluster.connectionString, + }; + if (cluster.oidcIssuer) { + result.oidcIssuer = cluster.oidcIssuer; + result.connectionStringWithOidc = cs.toString(); + } + if (disaggregatedStorage && 'sls' in disaggregatedStorage) { + result.sls = { + ports: disaggregatedStorage.sls.ports, + services: disaggregatedStorage.sls.services, + }; + } + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(cs.toString()); + } cluster.unref(); } @@ -183,8 +214,16 @@ import type { MongoClientOptions } from 'mongodb'; } async function ls() { + const entries: { id: string; connectionString: string }[] = []; for await (const { id, connectionString } of utilities.instances(argv)) { - console.log(`${id}: ${connectionString}`); + if (argv.json) { + entries.push({ id, connectionString }); + } else { + console.log(`${id}: ${connectionString}`); + } + } + if (argv.json) { + console.log(JSON.stringify(entries, null, 2)); } } diff --git a/packages/mongodb-runner/src/ecr.spec.ts b/packages/mongodb-runner/src/ecr.spec.ts new file mode 100644 index 00000000..33352100 --- /dev/null +++ b/packages/mongodb-runner/src/ecr.spec.ts @@ -0,0 +1,279 @@ +import { expect } from 'chai'; +import { dockerLoginToEcr, maybeLoginToEcr, parseEcrRegistry } from './ecr'; + +describe('ecr', function () { + describe('parseEcrRegistry', function () { + it('parses the default SLS image repository', function () { + expect( + parseEcrRegistry( + '664315256653.dkr.ecr.us-east-1.amazonaws.com/disagg-storage/', + ), + 'default SLS repo should be recognized as ECR', + ).to.deep.equal({ + registry: '664315256653.dkr.ecr.us-east-1.amazonaws.com', + registryId: '664315256653', + region: 'us-east-1', + }); + }); + + it('parses a registry host with no repository path', function () { + expect( + parseEcrRegistry('123456789012.dkr.ecr.eu-west-2.amazonaws.com'), + 'bare registry host should parse', + ).to.deep.equal({ + registry: '123456789012.dkr.ecr.eu-west-2.amazonaws.com', + registryId: '123456789012', + region: 'eu-west-2', + }); + }); + + it('returns undefined for a non-ECR repository', function () { + expect( + parseEcrRegistry('docker.io/library/'), + 'docker.io is not ECR and must not trigger a login', + ).to.equal(undefined); + }); + + it('returns undefined for a lookalike host', function () { + expect( + parseEcrRegistry('evil.amazonaws.com.attacker.test/x/'), + 'host must actually end in .amazonaws.com to count as ECR', + ).to.equal(undefined); + }); + + it('returns undefined when the account ID is not 12 digits', function () { + expect( + parseEcrRegistry('12345.dkr.ecr.us-east-1.amazonaws.com/x/'), + 'AWS account IDs are always 12 digits, so a shorter one is not a registry', + ).to.equal(undefined); + }); + }); + + describe('dockerLoginToEcr', function () { + const registry = { + registry: '664315256653.dkr.ecr.us-east-1.amazonaws.com', + registryId: '664315256653', + region: 'us-east-1', + }; + + // Builds a fake execFile matching the real callback-style signature, + // recording invocations and anything written to the child's stdin. + function fakeExecFile( + respond: (cmd: string) => { stdout?: string; error?: Error }, + ) { + const calls: { cmd: string; args: string[] }[] = []; + let stdinData = ''; + const impl = (cmd: string, args: string[], opts: any, cb?: any) => { + const callback = typeof opts === 'function' ? opts : cb; + calls.push({ cmd, args }); + const { stdout = '', error } = respond(cmd); + if (error) callback(error); + else callback(null, stdout, ''); + return { + stdin: { + on() { + /* no-op */ + }, + write(chunk: string) { + stdinData += chunk; + }, + end() { + /* no-op */ + }, + }, + } as any; + }; + return { impl, calls, stdin: () => stdinData }; + } + + it('requests a token scoped to the target registry id', async function () { + const token = Buffer.from('AWS:pa:ss:word').toString('base64'); + const { impl, calls } = fakeExecFile((cmd) => + cmd === 'aws' + ? { stdout: `${token}\n` } + : { stdout: 'Login Succeeded' }, + ); + + await dockerLoginToEcr(registry, { execFile: impl as any }); + + const awsCall = calls.find((c) => c.cmd === 'aws'); + expect(awsCall, 'aws CLI should have been invoked').to.not.equal( + undefined, + ); + expect( + awsCall!.args, + 'must scope the token to the target account, not the callers own', + ).to.include.members(['--registry-ids', '664315256653']); + expect( + awsCall!.args, + 'get-login-password mints a token for the wrong account', + ).to.not.include('get-login-password'); + expect(awsCall!.args, 'region must be passed').to.include.members([ + '--region', + 'us-east-1', + ]); + }); + + it('logs in to docker with the decoded password', async function () { + const token = Buffer.from('AWS:pa:ss:word').toString('base64'); + const { impl, stdin } = fakeExecFile((cmd) => + cmd === 'aws' + ? { stdout: `${token}\n` } + : { stdout: 'Login Succeeded' }, + ); + + await dockerLoginToEcr(registry, { execFile: impl as any }); + + expect( + stdin(), + 'password must be split on the first colon only, since it contains colons', + ).to.equal('pa:ss:word'); + }); + + it('explains how to authenticate manually when the aws CLI is missing', async function () { + const enoent = Object.assign(new Error('spawn aws ENOENT'), { + code: 'ENOENT', + }); + const { impl } = fakeExecFile(() => ({ error: enoent })); + + const err = await dockerLoginToEcr(registry, { + execFile: impl as any, + }).catch((e: Error) => e); + + expect(err, 'missing aws CLI must reject').to.be.instanceOf(Error); + expect( + (err as Error).message, + 'error should name the AWS CLI as the missing prerequisite', + ).to.include('AWS CLI'); + expect( + (err as Error).message, + 'error should offer the opt-out flag', + ).to.include('--slsSkipEcrLogin'); + }); + + for (const [description, output] of [ + ['a plain error string', 'An error occurred (AccessDenied)'], + ['the literal None', 'None'], + ['empty output', ''], + ['a token with an empty password', 'AWS:'], + ['a token for an unexpected user', 'someoneelse:hunter2'], + ] as const) { + it(`rejects ${description} instead of using it as a password`, async function () { + const { impl } = fakeExecFile((cmd) => + cmd === 'aws' + ? { stdout: `${Buffer.from(output).toString('base64')}\n` } + : { stdout: 'Login Succeeded' }, + ); + + const err = await dockerLoginToEcr(registry, { + execFile: impl as any, + }).catch((e: Error) => e); + + expect( + err, + 'non-token AWS CLI output must not be passed to docker login', + ).to.be.instanceOf(Error); + expect( + (err as Error).message, + 'error should say the token was not of the expected form', + ).to.include("'AWS:'"); + }); + } + + it('reports a missing docker binary as such, not as a permissions problem', async function () { + const token = Buffer.from('AWS:secret').toString('base64'); + const { impl } = fakeExecFile((cmd) => + cmd === 'aws' + ? { stdout: `${token}\n` } + : { + error: Object.assign(new Error('spawn docker ENOENT'), { + code: 'ENOENT', + }), + }, + ); + + const err = await dockerLoginToEcr(registry, { + execFile: impl as any, + }).catch((e: Error) => e); + + expect( + (err as Error).message, + 'a missing docker binary is not an ECR permissions failure', + ).to.not.include('ecr:BatchGetImage'); + expect( + (err as Error).message, + 'error should name docker as the missing prerequisite', + ).to.include('not found on PATH'); + }); + + it('explains the pull permission trap when docker login fails', async function () { + const token = Buffer.from('AWS:secret').toString('base64'); + const { impl } = fakeExecFile((cmd) => + cmd === 'aws' + ? { stdout: `${token}\n` } + : { + error: Object.assign(new Error('exited 1'), { + stderr: 'status: 400 Bad Request', + }), + }, + ); + + const err = await dockerLoginToEcr(registry, { + execFile: impl as any, + }).catch((e: Error) => e); + + expect( + (err as Error).message, + 'a bare 400 is useless; name the permission actually needed to pull', + ).to.include('ecr:BatchGetImage'); + }); + }); + + describe('maybeLoginToEcr', function () { + // Records whether any subprocess was spawned at all. + function spyExecFile() { + const state = { called: false }; + const impl = (...args: any[]) => { + state.called = true; + args[args.length - 1](null, '', ''); + return { + stdin: { + on() { + /* no-op */ + }, + write() { + /* no-op */ + }, + end() { + /* no-op */ + }, + }, + } as any; + }; + return { impl, state }; + } + + it('skips non-ECR repositories entirely', async function () { + const { impl, state } = spyExecFile(); + await maybeLoginToEcr('docker.io/library/', true, { + execFile: impl as any, + }); + expect( + state.called, + 'must not shell out for a non-ECR repository', + ).to.equal(false); + }); + + it('skips when login is disabled', async function () { + const { impl, state } = spyExecFile(); + await maybeLoginToEcr( + '664315256653.dkr.ecr.us-east-1.amazonaws.com/disagg-storage/', + false, + { execFile: impl as any }, + ); + expect(state.called, 'ecrLogin=false must suppress the login').to.equal( + false, + ); + }); + }); +}); diff --git a/packages/mongodb-runner/src/ecr.ts b/packages/mongodb-runner/src/ecr.ts new file mode 100644 index 00000000..6a77d37b --- /dev/null +++ b/packages/mongodb-runner/src/ecr.ts @@ -0,0 +1,187 @@ +import { execFile as execFileCb } from 'child_process'; +import { debug } from './util'; + +/** An Amazon ECR registry that SLS container images are pulled from. */ +export interface EcrRegistry { + /** Registry host, e.g. `664315256653.dkr.ecr.us-east-1.amazonaws.com`. */ + registry: string; + /** AWS account ID owning the registry. */ + registryId: string; + /** AWS region the registry lives in. */ + region: string; +} + +const ECR_HOST_RE = + /^(?\d{12})\.dkr\.ecr\.(?[a-z0-9-]+)\.amazonaws\.com$/; + +/** + * Identify the ECR registry a docker image repository refers to, or + * `undefined` if the repository is not hosted on ECR. + */ +export function parseEcrRegistry(imageRepo: string): EcrRegistry | undefined { + const [host] = imageRepo.split('/', 1); + const match = ECR_HOST_RE.exec(host); + if (!match?.groups) return undefined; + const { registryId, region } = match.groups; + return { registry: host, registryId, region }; +} + +/** + * Injection point for tests. Exported because it appears in the signatures of + * the exported functions below, but deliberately not re-exported from the + * package entrypoint. + */ +export interface EcrLoginDeps { + execFile: typeof execFileCb; +} + +function manualLoginCommand(registry: EcrRegistry): string { + return ( + `aws ecr get-authorization-token --region ${registry.region} ` + + `--registry-ids ${registry.registryId} ` + + `--query 'authorizationData[0].authorizationToken' --output text | ` + + `base64 -d | cut -d: -f2- | ` + + `docker login --username AWS --password-stdin ${registry.registry}` + ); +} + +async function getAuthorizationToken( + registry: EcrRegistry, + execFile: typeof execFileCb, +): Promise { + return await new Promise((resolve, reject) => { + execFile( + 'aws', + [ + 'ecr', + 'get-authorization-token', + '--region', + registry.region, + // Scoping to the target account is essential. Without it -- as with + // the more familiar `get-login-password` -- the token is minted for + // the caller's own registry, and docker login rejects it with a bare + // `status: 400 Bad Request`. + '--registry-ids', + registry.registryId, + '--query', + 'authorizationData[0].authorizationToken', + '--output', + 'text', + ], + (err, stdout) => { + if (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return reject( + new Error( + `The AWS CLI is required to authenticate to ${registry.registry}. ` + + `Install it, or authenticate manually with:\n ${manualLoginCommand( + registry, + )}\nIf you have already authenticated another way, pass --slsSkipEcrLogin.`, + ), + ); + } + return reject(err); + } + resolve(String(stdout).trim()); + }, + ); + }); +} + +async function dockerLogin( + registry: EcrRegistry, + password: string, + execFile: typeof execFileCb, +): Promise { + await new Promise((resolve, reject) => { + const proc = execFile( + 'docker', + ['login', '--username', 'AWS', '--password-stdin', registry.registry], + (err) => { + if (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return reject( + new Error( + `docker is required to authenticate to ${registry.registry}, but was not found on PATH. ` + + `Install Docker, or pass --slsSkipEcrLogin if you have already authenticated another way.`, + ), + ); + } + const stderr = String( + (err as { stderr?: string }).stderr ?? '', + ).trim(); + return reject( + new Error( + `docker login to ${registry.registry} failed: ${ + stderr || err.message + }\nNote that minting a token only requires ecr:GetAuthorizationToken in your ` + + `own AWS account, while pulling additionally requires ecr:BatchGetImage granted ` + + `by a resource policy on the repositories in account ${registry.registryId}. ` + + `A token that mints successfully but cannot pull means the latter is missing.`, + ), + ); + } + resolve(); + }, + ); + // A failed spawn or an early exit makes this write emit EPIPE/ENOENT on the + // stream; the execFile callback already reports the real failure, so + // swallowing it here just keeps it from becoming an uncaught exception. + proc.stdin?.on('error', () => undefined); + proc.stdin?.write(password); + proc.stdin?.end(); + }); +} + +/** + * Authenticate the local docker daemon against an ECR registry so that SLS + * images can be pulled. + */ +export async function dockerLoginToEcr( + registry: EcrRegistry, + deps: Partial = {}, +): Promise { + const execFile = deps.execFile ?? execFileCb; + debug('logging in to ECR registry', registry); + const token = await getAuthorizationToken(registry, execFile); + const decoded = Buffer.from(token, 'base64').toString('utf8'); + // The token decodes to `AWS:`. The password itself contains colons, + // so only the first one separates the two fields. Check the username rather + // than just finding a colon, so that non-token output from the AWS CLI (an + // error string, a warning, `None`) is reported as such instead of being + // sliced into a bogus password. + const separator = decoded.indexOf(':'); + const password = separator === -1 ? '' : decoded.slice(separator + 1); + if (decoded.slice(0, separator) !== 'AWS' || !password) { + throw new Error( + `Expected an ECR authorization token for ${registry.registry} of the form ` + + `'AWS:', but the AWS CLI returned something else. Run the ` + + `command by hand to see what it produced:\n ${manualLoginCommand( + registry, + )}`, + ); + } + await dockerLogin(registry, password, execFile); + debug('ECR login succeeded', { registry: registry.registry }); +} + +/** + * Log in to the registry backing `imageRepo`, if it is an ECR registry and + * `enabled` is set. A no-op otherwise. + */ +export async function maybeLoginToEcr( + imageRepo: string, + enabled: boolean, + deps: Partial = {}, +): Promise { + if (!enabled) { + debug('skipping ECR login (disabled)'); + return; + } + const registry = parseEcrRegistry(imageRepo); + if (!registry) { + debug('skipping ECR login (not an ECR repository)', { imageRepo }); + return; + } + await dockerLoginToEcr(registry, deps); +} diff --git a/packages/mongodb-runner/src/index.ts b/packages/mongodb-runner/src/index.ts index 8ab97040..3b06664b 100644 --- a/packages/mongodb-runner/src/index.ts +++ b/packages/mongodb-runner/src/index.ts @@ -22,6 +22,7 @@ export { type SLSDisaggregatedStorageConfigOptions, type SLSDisaggregatedStorageSetupOptions, parseSLSComposeServices, + readPinnedSlsCommit, SLS_HOSTNAME, SLS_CELL1, SLS_CELL2, @@ -31,6 +32,12 @@ export { type SLSMultiCellEnvironment, type SLSMultiCellEnvironmentOptions, } from './sls'; +export { + parseEcrRegistry, + dockerLoginToEcr, + maybeLoginToEcr, + type EcrRegistry, +} from './ecr'; export { DockerComposeProject, type DockerComposeProjectOptions, diff --git a/packages/mongodb-runner/src/sls.spec.ts b/packages/mongodb-runner/src/sls.spec.ts new file mode 100644 index 00000000..540b0aa0 --- /dev/null +++ b/packages/mongodb-runner/src/sls.spec.ts @@ -0,0 +1,47 @@ +import { expect } from 'chai'; +import path from 'path'; +import { readPinnedSlsCommit } from './sls'; + +const FIXTURES = path.resolve(__dirname, '..', 'test', 'fixtures', 'sls'); + +describe('readPinnedSlsCommit', function () { + it('reads pinned_sls_commit from a manifest', async function () { + expect( + await readPinnedSlsCommit(path.join(FIXTURES, 'complete')), + 'should return the pinned commit verbatim', + ).to.equal('abc123def456'); + }); + + it('names the path it looked at when the manifest is absent', async function () { + const missing = path.join(FIXTURES, 'does-not-exist'); + const err = await readPinnedSlsCommit(missing).catch((e: Error) => e); + expect( + (err as Error).message, + 'error should name the manifest path that was checked', + ).to.include(path.join(missing, 'manifest.json')); + expect( + (err as Error).message, + 'error should mention the override flag', + ).to.include('--slsImageTag'); + }); + + it('reports a manifest that is missing the key', async function () { + const err = await readPinnedSlsCommit(path.join(FIXTURES, 'no-key')).catch( + (e: Error) => e, + ); + expect( + (err as Error).message, + 'error should name the missing key', + ).to.include('pinned_sls_commit'); + }); + + it('reports a malformed manifest', async function () { + const err = await readPinnedSlsCommit( + path.join(FIXTURES, 'malformed'), + ).catch((e: Error) => e); + expect( + (err as Error).message, + 'error should say the manifest could not be parsed', + ).to.match(/parse/i); + }); +}); diff --git a/packages/mongodb-runner/src/sls.ts b/packages/mongodb-runner/src/sls.ts index 00580f38..7b1b7fe7 100644 --- a/packages/mongodb-runner/src/sls.ts +++ b/packages/mongodb-runner/src/sls.ts @@ -4,6 +4,7 @@ import path from 'path'; import { execFile as execFileCb } from 'child_process'; import { promisify } from 'util'; import { debug, allocatePort, sleep, uuid } from './util'; +import { maybeLoginToEcr } from './ecr'; import type { DisaggregatedStorageOptions, ShardDescriptor, @@ -25,6 +26,44 @@ export const SLS_HOSTNAME = 'local.sls.mmscloudteam.com'; const DEFAULT_SLS_IMAGE_REPO = '664315256653.dkr.ecr.us-east-1.amazonaws.com/disagg-storage/'; +/** Name of the build manifest inside a Server build's atlas module directory. */ +export const SLS_MANIFEST_FILE = 'manifest.json'; + +/** + * Read `pinned_sls_commit` from a Server build's + * `buildscripts/modules/atlas/manifest.json`. This is the SLS image tag + * matching the commit the binaries were built from. + */ +export async function readPinnedSlsCommit(atlasDir: string): Promise { + const manifestPath = path.join(atlasDir, SLS_MANIFEST_FILE); + let contents: string; + try { + contents = await fs.readFile(manifestPath, 'utf8'); + } catch (err) { + throw new Error( + `Could not read the SLS build manifest at ${manifestPath}: ` + + `${(err as Error).message}. Pass --slsImageTag explicitly to skip this lookup.`, + ); + } + let manifest: unknown; + try { + manifest = JSON.parse(contents); + } catch (err) { + throw new Error( + `Could not parse the SLS build manifest at ${manifestPath}: ` + + `${(err as Error).message}. Pass --slsImageTag explicitly to skip this lookup.`, + ); + } + const pinned = (manifest as Record)?.pinned_sls_commit; + if (typeof pinned !== 'string' || !pinned) { + throw new Error( + `The SLS build manifest at ${manifestPath} has no pinned_sls_commit key. ` + + `Pass --slsImageTag explicitly to skip this lookup.`, + ); + } + return pinned; +} + export interface SLSServiceInfo { /** Environment variable through which the compose file receives the host port. */ portVar: string; @@ -95,10 +134,10 @@ export interface SLSMultiCellEnvironmentOptions { */ composeFile: string; /** - * Image tag for the SLS images (typically the `pinned_sls_commit` from the - * server repository's buildscripts/modules/atlas/manifest.json). + * Image tag for the SLS images. Defaults to the `pinned_sls_commit` from + * the `manifest.json` sitting next to the compose file. */ - imageTag: string; + imageTag?: string; /** Docker image repository for SLS images. */ imageRepo?: string; /** Docker image repository for third-party images (default: imageRepo with 'disagg-storage' replaced by 'thirdparty'). */ @@ -129,7 +168,9 @@ export interface SLSMultiCellEnvironment { export async function createSLSMultiCellEnvironment( options: SLSMultiCellEnvironmentOptions, ): Promise { - const { composeFile, imageTag } = options; + const { composeFile } = options; + const imageTag = + options.imageTag ?? (await readPinnedSlsCommit(path.dirname(composeFile))); const serviceInfo = parseSLSComposeServices( await fs.readFile(composeFile, 'utf8'), @@ -259,6 +300,11 @@ export interface SLSDisaggregatedStorageSetupOptions extends SLSMultiCellEnviron setupMaxRetries?: number; /** Interval between per-shard log setup attempts in ms (default: 2000). */ setupRetryIntervalMs?: number; + /** + * Authenticate the local docker daemon against the image repository before + * pulling, when it is an Amazon ECR registry (default: true). + */ + ecrLogin?: boolean; } // Well-known test encryption key, matching createKeyFile() in the server @@ -298,6 +344,9 @@ export async function createSLSDisaggregatedStorageOptions( options: SLSDisaggregatedStorageSetupOptions, ): Promise { const sls = await createSLSMultiCellEnvironment(options); + // Read the repository back out of the environment we just built so the login + // target cannot drift from the repository the images are actually pulled from. + await maybeLoginToEcr(sls.env.SLS_IMAGE_REPO, options.ecrLogin ?? true); const projectName = options.projectName ?? `mongodb-runner-sls-${uuid()}`; const testdriverContainer = `${projectName}-testdriver-1`; const firstLogId = options.firstLogId ?? 1; diff --git a/packages/mongodb-runner/test/fixtures/sls/complete/manifest.json b/packages/mongodb-runner/test/fixtures/sls/complete/manifest.json new file mode 100644 index 00000000..bfc2ac60 --- /dev/null +++ b/packages/mongodb-runner/test/fixtures/sls/complete/manifest.json @@ -0,0 +1,4 @@ +{ + "pinned_sls_commit": "abc123def456", + "other_key": "ignored" +} diff --git a/packages/mongodb-runner/test/fixtures/sls/malformed/manifest.json b/packages/mongodb-runner/test/fixtures/sls/malformed/manifest.json new file mode 100644 index 00000000..dcf31480 --- /dev/null +++ b/packages/mongodb-runner/test/fixtures/sls/malformed/manifest.json @@ -0,0 +1 @@ +{ this is not valid json diff --git a/packages/mongodb-runner/test/fixtures/sls/no-key/manifest.json b/packages/mongodb-runner/test/fixtures/sls/no-key/manifest.json new file mode 100644 index 00000000..9aa8eadb --- /dev/null +++ b/packages/mongodb-runner/test/fixtures/sls/no-key/manifest.json @@ -0,0 +1,3 @@ +{ + "other_key": "present but not the one we need" +}