From 350d0decba9e23d200672be501645d0253c742c8 Mon Sep 17 00:00:00 2001 From: Dave Rolsky Date: Fri, 28 Aug 2026 13:30:32 -0500 Subject: [PATCH 1/3] feat(mongodb-runner): parse ECR registry from SLS image repo --- packages/mongodb-runner/src/ecr.spec.ts | 49 +++++++++++++++++++++++++ packages/mongodb-runner/src/ecr.ts | 24 ++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 packages/mongodb-runner/src/ecr.spec.ts create mode 100644 packages/mongodb-runner/src/ecr.ts diff --git a/packages/mongodb-runner/src/ecr.spec.ts b/packages/mongodb-runner/src/ecr.spec.ts new file mode 100644 index 00000000..3d9dbe1f --- /dev/null +++ b/packages/mongodb-runner/src/ecr.spec.ts @@ -0,0 +1,49 @@ +import { expect } from 'chai'; +import { parseEcrRegistry } from './ecr'; + +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); + }); +}); diff --git a/packages/mongodb-runner/src/ecr.ts b/packages/mongodb-runner/src/ecr.ts new file mode 100644 index 00000000..0b2f9a1a --- /dev/null +++ b/packages/mongodb-runner/src/ecr.ts @@ -0,0 +1,24 @@ +/** 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 }; +} From ea860a5e0f73d579621567a96d97698f78415681 Mon Sep 17 00:00:00 2001 From: Dave Rolsky Date: Fri, 28 Aug 2026 13:33:13 -0500 Subject: [PATCH 2/3] feat(mongodb-runner): add ECR docker login helper --- packages/mongodb-runner/src/ecr.spec.ts | 228 ++++++++++++++++++++---- packages/mongodb-runner/src/ecr.ts | 130 ++++++++++++++ 2 files changed, 321 insertions(+), 37 deletions(-) diff --git a/packages/mongodb-runner/src/ecr.spec.ts b/packages/mongodb-runner/src/ecr.spec.ts index 3d9dbe1f..4db4a592 100644 --- a/packages/mongodb-runner/src/ecr.spec.ts +++ b/packages/mongodb-runner/src/ecr.spec.ts @@ -1,49 +1,203 @@ import { expect } from 'chai'; -import { parseEcrRegistry } from './ecr'; - -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({ +import { dockerLoginToEcr, 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: { + 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('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('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('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('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 })); - 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); - }); + 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'); + }); - 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); + 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('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'); + }); }); + }); diff --git a/packages/mongodb-runner/src/ecr.ts b/packages/mongodb-runner/src/ecr.ts index 0b2f9a1a..d03f95f2 100644 --- a/packages/mongodb-runner/src/ecr.ts +++ b/packages/mongodb-runner/src/ecr.ts @@ -1,3 +1,6 @@ +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`. */ @@ -22,3 +25,130 @@ export function parseEcrRegistry(imageRepo: string): EcrRegistry | 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) { + 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(); + }, + ); + 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 }); +} From 5f9b62d6db0c1f36d32db940045ed6883b187111 Mon Sep 17 00:00:00 2001 From: Dave Rolsky Date: Fri, 28 Aug 2026 13:36:23 -0500 Subject: [PATCH 3/3] feat(mongodb-runner): authenticate to ECR automatically for DSC clusters --- .../docs/disaggregated-storage.md | 36 ++++++--- packages/mongodb-runner/src/cli.ts | 7 ++ packages/mongodb-runner/src/ecr.spec.ts | 78 ++++++++++++++++++- packages/mongodb-runner/src/ecr.ts | 33 ++++++++ packages/mongodb-runner/src/index.ts | 6 ++ packages/mongodb-runner/src/sls.ts | 9 +++ 6 files changed, 159 insertions(+), 10 deletions(-) diff --git a/packages/mongodb-runner/docs/disaggregated-storage.md b/packages/mongodb-runner/docs/disaggregated-storage.md index 5a0f24e8..31e7c459 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: @@ -59,14 +81,10 @@ variables, readiness polling, per-shard log creation, and the 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 +# 1. 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 +# 2. Start a 2-node replica set backed by SLS (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 \ diff --git a/packages/mongodb-runner/src/cli.ts b/packages/mongodb-runner/src/cli.ts index 164ed3cf..a11d9dc8 100644 --- a/packages/mongodb-runner/src/cli.ts +++ b/packages/mongodb-runner/src/cli.ts @@ -94,6 +94,12 @@ import type { MongoClientOptions } from 'mongodb'; describe: 'SLS docker image tag to use with --slsCompose (e.g. the pinned_sls_commit from the server repo manifest)', }) + .option('slsSkipEcrLogin', { + type: 'boolean', + default: false, + describe: + 'Skip authenticating to the SLS image registry (use if you have already run docker login)', + }) .option('debug', { type: 'boolean', describe: 'Enable debug output' }) .option('verbose', { type: 'boolean', describe: 'Enable verbose output' }) .command('start', 'Start a MongoDB instance') @@ -134,6 +140,7 @@ import type { MongoClientOptions } from 'mongodb'; ? await utilities.createSLSDisaggregatedStorageOptions({ composeFile: argv.slsCompose, imageTag: argv.slsImageTag!, + ecrLogin: !argv.slsSkipEcrLogin, }) : undefined; if (disaggregatedStorage && 'sls' in disaggregatedStorage) { diff --git a/packages/mongodb-runner/src/ecr.spec.ts b/packages/mongodb-runner/src/ecr.spec.ts index 4db4a592..33352100 100644 --- a/packages/mongodb-runner/src/ecr.spec.ts +++ b/packages/mongodb-runner/src/ecr.spec.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { dockerLoginToEcr, parseEcrRegistry } from './ecr'; +import { dockerLoginToEcr, maybeLoginToEcr, parseEcrRegistry } from './ecr'; describe('ecr', function () { describe('parseEcrRegistry', function () { @@ -71,6 +71,9 @@ describe('ecr', function () { else callback(null, stdout, ''); return { stdin: { + on() { + /* no-op */ + }, write(chunk: string) { stdinData += chunk; }, @@ -177,6 +180,32 @@ describe('ecr', function () { }); } + 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) => @@ -200,4 +229,51 @@ describe('ecr', function () { }); }); + 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 index d03f95f2..6a77d37b 100644 --- a/packages/mongodb-runner/src/ecr.ts +++ b/packages/mongodb-runner/src/ecr.ts @@ -99,6 +99,14 @@ async function dockerLogin( ['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(); @@ -116,6 +124,10 @@ async function dockerLogin( 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(); }); @@ -152,3 +164,24 @@ export async function dockerLoginToEcr( 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..8c17a5b7 100644 --- a/packages/mongodb-runner/src/index.ts +++ b/packages/mongodb-runner/src/index.ts @@ -31,6 +31,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.ts b/packages/mongodb-runner/src/sls.ts index 00580f38..1a9b9e88 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, @@ -259,6 +260,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 +304,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;