Skip to content

Commit c2c87d9

Browse files
authored
fix(pipelines): DockerCredential.dockerHub() silently fails auth (#18313)
### Problem: `DockerCredential.dockerHub()` silently failed to authenticate users, resulting in unexpected and intermittent throttling due to docker's policy for unauthenticated users. ### Reason: `.dockerHub()` added `index.docker.io` to the domain credentials, but the actual docker command [authenticated](https://github.com/moby/moby/blob/1e71c6cffedb79e3def696652753ea43cdc47b99/registry/config.go#L35) with `https://index.docker.io/v1/` which it was unable to find as a domain credential, thus failing to trigger `docker-credential-cdk-assets` during the `docker --config build` call. Furthermore, the credential `DockerCredential.customRegistry('https://index.docker.io/v1/', secret)` alone does not work. This would successfully trigger `docker-credential-cdk-assets` but fail to authenticate because of how `cdk-assets` handles credential lookup. The command strips the endpoint into just a hostname so in this case we try `fetchDockerLoginCredentials(awsClient, config, 'index.docker.io')` which fails: https://github.com/aws/aws-cdk/blob/4fb0309e3b93be276ab3e2d510ffc2ce35823dcd/packages/cdk-assets/bin/docker-credential-cdk-assets.ts#L32-L38 So the workaround for this bug was to specify both domains as credentials, each to satisfy a separate step of the process: ```ts dockerCredentials: [ pipelines.DockerCredential.dockerHub(secret), pipelines.DockerCredential.customRegistry('https://index.docker.io/v1/', secret), ], ``` ### Solution: This PR introduces two separate changes to address both problems. First, we change the hardcoded domain in `DockerCredential.dockerHub()` to be `https://index.docker.io/v1/`. This allows us to successfully trigger `docker-credential-cdk-assets` when the `docker --config build` command is called. Next, to make sure the credential lookup succeeds, we check for both the complete endpoint and the domain name. In this case, we will check for both `https://index.docker.io/v1/` as well as `index.docker.io`. Since `https://index.docker.io/v1/` exists in the credentials helper, authentication will succeed. Why do we still check for the domain `index.docker.io`? I don't know how custom registries or ecr works in this context and believe it to be beyond the scope of the PR. It's possible that they require the domain only for lookup. ### Testing: The change to credential lookups is unit tested in `docker-credentials.test.ts`. I confirmed that the change to `DockerCredential.dockerHub()` is successful by configuring a mock `cdk-docker-creds.json` file and successfully `cdk deploy`ing a docker image that depends on a private repository. This isn't a common use case but ensures that failure to authenticate results in failure every time. Thanks @james-mathiesen for the suggestion. ### Contributors: Thanks to @nohack for the code in `cdk-assets`. Fixes #15737. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 4992d26 commit c2c87d9

File tree

6 files changed

+36
-16
lines changed

6 files changed

+36
-16
lines changed

packages/@aws-cdk/pipelines/lib/docker-credentials.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import { Fn } from '@aws-cdk/core';
1010
export abstract class DockerCredential {
1111
/**
1212
* Creates a DockerCredential for DockerHub.
13-
* Convenience method for `fromCustomRegistry('index.docker.io', opts)`.
13+
* Convenience method for `customRegistry('https://index.docker.io/v1/', opts)`.
1414
*/
1515
public static dockerHub(secret: secretsmanager.ISecret, opts: ExternalDockerCredentialOptions = {}): DockerCredential {
16-
return new ExternalDockerCredential('index.docker.io', secret, opts);
16+
return new ExternalDockerCredential('https://index.docker.io/v1/', secret, opts);
1717
}
1818

1919
/**

packages/@aws-cdk/pipelines/test/docker-credentials.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ describe('ExternalDockerCredential', () => {
2929
test('dockerHub defaults registry domain', () => {
3030
const creds = cdkp.DockerCredential.dockerHub(secret);
3131

32-
expect(Object.keys(creds._renderCdkAssetsConfig())).toEqual(['index.docker.io']);
32+
expect(Object.keys(creds._renderCdkAssetsConfig())).toEqual(['https://index.docker.io/v1/']);
3333
});
3434

3535
test('minimal example only renders secret', () => {

packages/cdk-assets/bin/docker-credential-cdk-assets.ts

+2-8
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,8 @@ async function main() {
2929
}
3030

3131
// Read the domain to fetch from stdin
32-
let rawDomain = fs.readFileSync(0, { encoding: 'utf-8' }).trim();
33-
// Paranoid handling to ensure new URL() doesn't throw if the schema is missing.
34-
// Not convinced docker will ever pass in a url like 'index.docker.io/v1', but just in case...
35-
rawDomain = rawDomain.includes('://') ? rawDomain : `https://${rawDomain}`;
36-
const domain = new URL(rawDomain).hostname;
37-
38-
const credentials = await fetchDockerLoginCredentials(new DefaultAwsClient(), config, domain);
39-
32+
let endpoint = fs.readFileSync(0, { encoding: 'utf-8' }).trim();
33+
const credentials = await fetchDockerLoginCredentials(new DefaultAwsClient(), config, endpoint);
4034
// Write the credentials back to stdout
4135
fs.writeFileSync(1, JSON.stringify(credentials));
4236
}

packages/cdk-assets/lib/private/docker-credentials.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,17 @@ export function cdkCredentialsConfig(): DockerCredentialsConfig | undefined {
3939
}
4040

4141
/** Fetches login credentials from the configured source (e.g., SecretsManager, ECR) */
42-
export async function fetchDockerLoginCredentials(aws: IAws, config: DockerCredentialsConfig, domain: string) {
43-
if (!Object.keys(config.domainCredentials).includes(domain)) {
42+
export async function fetchDockerLoginCredentials(aws: IAws, config: DockerCredentialsConfig, endpoint: string) {
43+
// Paranoid handling to ensure new URL() doesn't throw if the schema is missing
44+
// For official docker registry, docker will pass https://index.docker.io/v1/
45+
endpoint = endpoint.includes('://') ? endpoint : `https://${endpoint}`;
46+
const domain = new URL(endpoint).hostname;
47+
48+
if (!Object.keys(config.domainCredentials).includes(domain) && !Object.keys(config.domainCredentials).includes(endpoint)) {
4449
throw new Error(`unknown domain ${domain}`);
4550
}
4651

47-
const domainConfig = config.domainCredentials[domain];
52+
let domainConfig = config.domainCredentials[domain] ?? config.domainCredentials[endpoint];
4853

4954
if (domainConfig.secretsManagerSecretId) {
5055
const sm = await aws.secretsManagerClient({ assumeRoleArn: domainConfig.assumeRoleArn });

packages/cdk-assets/lib/private/docker.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,17 @@ export class Docker {
124124
private async execute(args: string[], options: ShellOptions = {}) {
125125
const configArgs = this.configDir ? ['--config', this.configDir] : [];
126126

127+
const pathToCdkAssets = path.resolve(__dirname, '..', '..', 'bin');
127128
try {
128-
await shell(['docker', ...configArgs, ...args], { logger: this.logger, ...options });
129+
await shell(['docker', ...configArgs, ...args], {
130+
logger: this.logger,
131+
...options,
132+
env: {
133+
...process.env,
134+
...options.env,
135+
PATH: `${pathToCdkAssets}${path.delimiter}${options.env?.PATH ?? process.env.PATH}`,
136+
},
137+
});
129138
} catch (e) {
130139
if (e.code === 'ENOENT') {
131140
throw new Error('Unable to execute \'docker\' in order to build a container asset. Please install \'docker\' and try again.');

packages/cdk-assets/test/private/docker-credentials.test.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,27 @@ describe('fetchDockerLoginCredentials', () => {
9797
await expect(fetchDockerLoginCredentials(aws, config, 'misconfigured.example.com')).rejects.toThrow(/unknown credential type/);
9898
});
9999

100+
test('does not throw on correctly configured raw domain', async () => {
101+
expect(fetchDockerLoginCredentials(aws, config, 'https://secret.example.com/v1/')).resolves;
102+
});
103+
100104
describe('SecretsManager', () => {
101-
test('returns the credentials sucessfully if configured correctly', async () => {
105+
test('returns the credentials sucessfully if configured correctly - domain', async () => {
102106
mockSecretWithSecretString({ username: 'secretUser', secret: 'secretPass' });
103107

104108
const creds = await fetchDockerLoginCredentials(aws, config, 'secret.example.com');
105109

106110
expect(creds).toEqual({ Username: 'secretUser', Secret: 'secretPass' });
107111
});
108112

113+
test('returns the credentials successfully if configured correctly - raw domain', async () => {
114+
mockSecretWithSecretString({ username: 'secretUser', secret: 'secretPass' });
115+
116+
const creds = await fetchDockerLoginCredentials(aws, config, 'https://secret.example.com');
117+
118+
expect(creds).toEqual({ Username: 'secretUser', Secret: 'secretPass' });
119+
});
120+
109121
test('throws when SecretsManager returns an error', async () => {
110122
const errMessage = "Secrets Manager can't find the specified secret.";
111123
aws.mockSecretsManager.getSecretValue = mockedApiFailure('ResourceNotFoundException', errMessage);

0 commit comments

Comments
 (0)