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
36 changes: 27 additions & 9 deletions packages/mongodb-runner/docs/disaggregated-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 \
Expand Down
7 changes: 7 additions & 0 deletions packages/mongodb-runner/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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) {
Expand Down
279 changes: 279 additions & 0 deletions packages/mongodb-runner/src/ecr.spec.ts
Original file line number Diff line number Diff line change
@@ -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:<password>'");
});
}

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,
);
});
});
});
Loading