Skip to content

Commit a58a803

Browse files
authored
fix(assets): parallel docker image publishing fails on macOS (#20117)
Changes container image publishing so that publishing doesn't re-run docker logins for the same repository and so logins are run one at a time. Fixes #20116 ---- ### All Submissions: * [x] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/master/CONTRIBUTING.md) ### Adding new Unconventional Dependencies: * [ ] This PR adds new unconventional dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/master/CONTRIBUTING.md/#adding-new-unconventional-dependencies) ### New Features * [ ] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/master/INTEGRATION_TESTS.md)? * [ ] Did you use `yarn integ` to deploy the infrastructure and generate the snapshot (i.e. `yarn integ` without `--dry-run`)? *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent f7732a1 commit a58a803

File tree

7 files changed

+251
-35
lines changed

7 files changed

+251
-35
lines changed

Diff for: packages/cdk-assets/lib/private/asset-handler.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { IAws } from '../aws';
22
import { EventType } from '../progress';
3+
import { DockerFactory } from './docker';
34

45
export interface IAssetHandler {
56
publish(): Promise<void>;
@@ -8,6 +9,7 @@ export interface IAssetHandler {
89
export interface IHandlerHost {
910
readonly aws: IAws;
1011
readonly aborted: boolean;
12+
readonly dockerFactory: DockerFactory;
1113

1214
emitMessage(type: EventType, m: string): void;
1315
}

Diff for: packages/cdk-assets/lib/private/docker.ts

+60
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as os from 'os';
33
import * as path from 'path';
44
import { cdkCredentialsConfig, obtainEcrCredentials } from './docker-credentials';
55
import { Logger, shell, ShellOptions } from './shell';
6+
import { createCriticalSection } from './util';
67

78
interface BuildOptions {
89
readonly directory: string;
@@ -146,6 +147,65 @@ export class Docker {
146147
}
147148
}
148149

150+
export interface DockerFactoryOptions {
151+
readonly repoUri: string;
152+
readonly ecr: AWS.ECR;
153+
readonly logger: (m: string) => void;
154+
}
155+
156+
/**
157+
* Helps get appropriately configured Docker instances during the container
158+
* image publishing process.
159+
*/
160+
export class DockerFactory {
161+
private enterLoggedInDestinationsCriticalSection = createCriticalSection();
162+
private loggedInDestinations = new Set<string>();
163+
164+
/**
165+
* Gets a Docker instance for building images.
166+
*/
167+
public async forBuild(options: DockerFactoryOptions): Promise<Docker> {
168+
const docker = new Docker(options.logger);
169+
170+
// Default behavior is to login before build so that the Dockerfile can reference images in the ECR repo
171+
// However, if we're in a pipelines environment (for example),
172+
// we may have alternative credentials to the default ones to use for the build itself.
173+
// If the special config file is present, delay the login to the default credentials until the push.
174+
// If the config file is present, we will configure and use those credentials for the build.
175+
let cdkDockerCredentialsConfigured = docker.configureCdkCredentials();
176+
if (!cdkDockerCredentialsConfigured) {
177+
await this.loginOncePerDestination(docker, options);
178+
}
179+
180+
return docker;
181+
}
182+
183+
/**
184+
* Gets a Docker instance for pushing images to ECR.
185+
*/
186+
public async forEcrPush(options: DockerFactoryOptions) {
187+
const docker = new Docker(options.logger);
188+
await this.loginOncePerDestination(docker, options);
189+
return docker;
190+
}
191+
192+
private async loginOncePerDestination(docker: Docker, options: DockerFactoryOptions) {
193+
// Changes: 012345678910.dkr.ecr.us-west-2.amazonaws.com/tagging-test
194+
// To this: 012345678910.dkr.ecr.us-west-2.amazonaws.com
195+
const repositoryDomain = options.repoUri.split('/')[0];
196+
197+
// Ensure one-at-a-time access to loggedInDestinations.
198+
await this.enterLoggedInDestinationsCriticalSection(async () => {
199+
if (this.loggedInDestinations.has(repositoryDomain)) {
200+
return;
201+
}
202+
203+
await docker.login(options.ecr);
204+
this.loggedInDestinations.add(repositoryDomain);
205+
});
206+
}
207+
}
208+
149209
function flatten(x: string[][]) {
150210
return Array.prototype.concat([], ...x);
151211
}

Diff for: packages/cdk-assets/lib/private/handlers/container-images.ts

+43-35
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ import { replaceAwsPlaceholders } from '../placeholders';
88
import { shell } from '../shell';
99

1010
export class ContainerImageAssetHandler implements IAssetHandler {
11-
private readonly docker = new Docker(m => this.host.emitMessage(EventType.DEBUG, m));
12-
1311
constructor(
1412
private readonly workDir: string,
1513
private readonly asset: DockerImageManifestEntry,
@@ -31,32 +29,60 @@ export class ContainerImageAssetHandler implements IAssetHandler {
3129
if (await this.destinationAlreadyExists(ecr, destination, imageUri)) { return; }
3230
if (this.host.aborted) { return; }
3331

34-
// Default behavior is to login before build so that the Dockerfile can reference images in the ECR repo
35-
// However, if we're in a pipelines environment (for example),
36-
// we may have alternative credentials to the default ones to use for the build itself.
37-
// If the special config file is present, delay the login to the default credentials until the push.
38-
// If the config file is present, we will configure and use those credentials for the build.
39-
let cdkDockerCredentialsConfigured = this.docker.configureCdkCredentials();
40-
if (!cdkDockerCredentialsConfigured) { await this.docker.login(ecr); }
32+
const containerImageDockerOptions = {
33+
repoUri,
34+
logger: (m: string) => this.host.emitMessage(EventType.DEBUG, m),
35+
ecr,
36+
};
37+
38+
const dockerForBuilding = await this.host.dockerFactory.forBuild(containerImageDockerOptions);
4139

42-
const localTagName = this.asset.source.executable
43-
? await this.buildExternalAsset(this.asset.source.executable)
44-
: await this.buildDirectoryAsset();
40+
const builder = new ContainerImageBuilder(dockerForBuilding, this.workDir, this.asset, this.host);
41+
const localTagName = await builder.build();
4542

4643
if (localTagName === undefined || this.host.aborted) {
4744
return;
4845
}
4946

5047
this.host.emitMessage(EventType.UPLOAD, `Push ${imageUri}`);
5148
if (this.host.aborted) { return; }
52-
await this.docker.tag(localTagName, imageUri);
5349

54-
if (cdkDockerCredentialsConfigured) {
55-
this.docker.resetAuthPlugins();
56-
await this.docker.login(ecr);
50+
await dockerForBuilding.tag(localTagName, imageUri);
51+
52+
const dockerForPushing = await this.host.dockerFactory.forEcrPush(containerImageDockerOptions);
53+
await dockerForPushing.push(imageUri);
54+
}
55+
56+
/**
57+
* Check whether the image already exists in the ECR repo
58+
*
59+
* Use the fields from the destination to do the actual check. The imageUri
60+
* should correspond to that, but is only used to print Docker image location
61+
* for user benefit (the format is slightly different).
62+
*/
63+
private async destinationAlreadyExists(ecr: AWS.ECR, destination: DockerImageDestination, imageUri: string): Promise<boolean> {
64+
this.host.emitMessage(EventType.CHECK, `Check ${imageUri}`);
65+
if (await imageExists(ecr, destination.repositoryName, destination.imageTag)) {
66+
this.host.emitMessage(EventType.FOUND, `Found ${imageUri}`);
67+
return true;
5768
}
5869

59-
await this.docker.push(imageUri);
70+
return false;
71+
}
72+
}
73+
74+
class ContainerImageBuilder {
75+
constructor(
76+
private readonly docker: Docker,
77+
private readonly workDir: string,
78+
private readonly asset: DockerImageManifestEntry,
79+
private readonly host: IHandlerHost) {
80+
}
81+
82+
async build(): Promise<string | undefined> {
83+
return this.asset.source.executable
84+
? this.buildExternalAsset(this.asset.source.executable)
85+
: this.buildDirectoryAsset();
6086
}
6187

6288
/**
@@ -84,7 +110,6 @@ export class ContainerImageAssetHandler implements IAssetHandler {
84110
* and is expected to return the generated image identifier on stdout.
85111
*/
86112
private async buildExternalAsset(executable: string[], cwd?: string): Promise<string | undefined> {
87-
88113
const assetPath = cwd ?? this.workDir;
89114

90115
this.host.emitMessage(EventType.BUILD, `Building Docker image using command '${executable}'`);
@@ -93,23 +118,6 @@ export class ContainerImageAssetHandler implements IAssetHandler {
93118
return (await shell(executable, { cwd: assetPath, quiet: true })).trim();
94119
}
95120

96-
/**
97-
* Check whether the image already exists in the ECR repo
98-
*
99-
* Use the fields from the destination to do the actual check. The imageUri
100-
* should correspond to that, but is only used to print Docker image location
101-
* for user benefit (the format is slightly different).
102-
*/
103-
private async destinationAlreadyExists(ecr: AWS.ECR, destination: DockerImageDestination, imageUri: string): Promise<boolean> {
104-
this.host.emitMessage(EventType.CHECK, `Check ${imageUri}`);
105-
if (await imageExists(ecr, destination.repositoryName, destination.imageTag)) {
106-
this.host.emitMessage(EventType.FOUND, `Found ${imageUri}`);
107-
return true;
108-
}
109-
110-
return false;
111-
}
112-
113121
private async buildImage(localTagName: string): Promise<void> {
114122
const source = this.asset.source;
115123
if (!source.directory) {

Diff for: packages/cdk-assets/lib/private/util.ts

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Creates a critical section, ensuring that at most one function can
3+
* enter the critical section at a time.
4+
*/
5+
export function createCriticalSection() {
6+
let lock = Promise.resolve();
7+
return async (criticalFunction: () => Promise<void>) => {
8+
const res = lock.then(() => criticalFunction());
9+
lock = res.catch(e => e);
10+
return res;
11+
};
12+
};

Diff for: packages/cdk-assets/lib/publishing.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AssetManifest, IManifestEntry } from './asset-manifest';
22
import { IAws } from './aws';
33
import { IHandlerHost } from './private/asset-handler';
4+
import { DockerFactory } from './private/docker';
45
import { makeAssetHandler } from './private/handlers';
56
import { EventType, IPublishProgress, IPublishProgressListener } from './progress';
67

@@ -76,6 +77,7 @@ export class AssetPublishing implements IPublishProgress {
7677
aws: this.options.aws,
7778
get aborted() { return self.aborted; },
7879
emitMessage(t, m) { self.progressEvent(t, m); },
80+
dockerFactory: new DockerFactory(),
7981
};
8082
}
8183

Diff for: packages/cdk-assets/test/docker-images.test.ts

+100
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,37 @@ beforeEach(() => {
3636
},
3737
},
3838
}),
39+
'/multi/cdk.out/assets.json': JSON.stringify({
40+
version: Manifest.version(),
41+
dockerImages: {
42+
theAsset1: {
43+
source: {
44+
directory: 'dockerdir',
45+
},
46+
destinations: {
47+
theDestination: {
48+
region: 'us-north-50',
49+
assumeRoleArn: 'arn:aws:role',
50+
repositoryName: 'repo',
51+
imageTag: 'theAsset1',
52+
},
53+
},
54+
},
55+
theAsset2: {
56+
source: {
57+
directory: 'dockerdir',
58+
},
59+
destinations: {
60+
theDestination: {
61+
region: 'us-north-50',
62+
assumeRoleArn: 'arn:aws:role',
63+
repositoryName: 'repo',
64+
imageTag: 'theAsset2',
65+
},
66+
},
67+
},
68+
},
69+
}),
3970
'/external/cdk.out/assets.json': JSON.stringify({
4071
version: Manifest.version(),
4172
dockerImages: {
@@ -295,3 +326,72 @@ test('when external credentials are present, explicit Docker config directories
295326

296327
expectAllSpawns();
297328
});
329+
330+
test('logging in only once for two assets', async () => {
331+
const pub = new AssetPublishing(AssetManifest.fromPath('/multi/cdk.out'), { aws, throwOnError: false });
332+
aws.mockEcr.describeImages = mockedApiFailure('ImageNotFoundException', 'File does not exist');
333+
aws.mockEcr.getAuthorizationToken = mockedApiResult({
334+
authorizationData: [
335+
{ authorizationToken: 'dXNlcjpwYXNz', proxyEndpoint: 'https://proxy.com/' },
336+
],
337+
});
338+
339+
const expectAllSpawns = mockSpawn(
340+
{ commandLine: ['docker', 'login', '--username', 'user', '--password-stdin', 'https://proxy.com/'] },
341+
{ commandLine: ['docker', 'inspect', 'cdkasset-theasset1'], exitCode: 1 },
342+
{ commandLine: ['docker', 'build', '--tag', 'cdkasset-theasset1', '.'], cwd: '/multi/cdk.out/dockerdir' },
343+
{ commandLine: ['docker', 'tag', 'cdkasset-theasset1', '12345.amazonaws.com/repo:theAsset1'] },
344+
{ commandLine: ['docker', 'push', '12345.amazonaws.com/repo:theAsset1'] },
345+
{ commandLine: ['docker', 'inspect', 'cdkasset-theasset2'], exitCode: 1 },
346+
{ commandLine: ['docker', 'build', '--tag', 'cdkasset-theasset2', '.'], cwd: '/multi/cdk.out/dockerdir' },
347+
{ commandLine: ['docker', 'tag', 'cdkasset-theasset2', '12345.amazonaws.com/repo:theAsset2'] },
348+
{ commandLine: ['docker', 'push', '12345.amazonaws.com/repo:theAsset2'] },
349+
);
350+
351+
await pub.publish();
352+
353+
expectAllSpawns();
354+
expect(true).toBeTruthy(); // Expect no exception, satisfy linter
355+
});
356+
357+
test('logging in twice for two repository domains (containing account id & region)', async () => {
358+
const pub = new AssetPublishing(AssetManifest.fromPath('/multi/cdk.out'), { aws, throwOnError: false });
359+
aws.mockEcr.describeImages = mockedApiFailure('ImageNotFoundException', 'File does not exist');
360+
361+
let repoIdx = 12345;
362+
aws.mockEcr.describeRepositories = jest.fn().mockReturnValue({
363+
promise: jest.fn().mockImplementation(() => Promise.resolve({
364+
repositories: [
365+
// Usually looks like: 012345678910.dkr.ecr.us-west-2.amazonaws.com/aws-cdk/assets
366+
{ repositoryUri: `${repoIdx++}.amazonaws.com/aws-cdk/assets` },
367+
],
368+
})),
369+
});
370+
371+
let proxyIdx = 12345;
372+
aws.mockEcr.getAuthorizationToken = jest.fn().mockReturnValue({
373+
promise: jest.fn().mockImplementation(() => Promise.resolve({
374+
authorizationData: [
375+
{ authorizationToken: 'dXNlcjpwYXNz', proxyEndpoint: `https://${proxyIdx++}.proxy.com/` },
376+
],
377+
})),
378+
});
379+
380+
const expectAllSpawns = mockSpawn(
381+
{ commandLine: ['docker', 'login', '--username', 'user', '--password-stdin', 'https://12345.proxy.com/'] },
382+
{ commandLine: ['docker', 'inspect', 'cdkasset-theasset1'], exitCode: 1 },
383+
{ commandLine: ['docker', 'build', '--tag', 'cdkasset-theasset1', '.'], cwd: '/multi/cdk.out/dockerdir' },
384+
{ commandLine: ['docker', 'tag', 'cdkasset-theasset1', '12345.amazonaws.com/aws-cdk/assets:theAsset1'] },
385+
{ commandLine: ['docker', 'push', '12345.amazonaws.com/aws-cdk/assets:theAsset1'] },
386+
{ commandLine: ['docker', 'login', '--username', 'user', '--password-stdin', 'https://12346.proxy.com/'] },
387+
{ commandLine: ['docker', 'inspect', 'cdkasset-theasset2'], exitCode: 1 },
388+
{ commandLine: ['docker', 'build', '--tag', 'cdkasset-theasset2', '.'], cwd: '/multi/cdk.out/dockerdir' },
389+
{ commandLine: ['docker', 'tag', 'cdkasset-theasset2', '12346.amazonaws.com/aws-cdk/assets:theAsset2'] },
390+
{ commandLine: ['docker', 'push', '12346.amazonaws.com/aws-cdk/assets:theAsset2'] },
391+
);
392+
393+
await pub.publish();
394+
395+
expectAllSpawns();
396+
expect(true).toBeTruthy(); // Expect no exception, satisfy linter
397+
});

Diff for: packages/cdk-assets/test/util.test.ts

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { createCriticalSection } from '../lib/private/util';
2+
3+
test('critical section', async () => {
4+
// GIVEN
5+
const criticalSection = createCriticalSection();
6+
7+
// WHEN
8+
const arr = new Array<string>();
9+
void criticalSection(async () => {
10+
await new Promise(res => setTimeout(res, 500));
11+
arr.push('first');
12+
});
13+
await criticalSection(async () => {
14+
arr.push('second');
15+
});
16+
17+
// THEN
18+
expect(arr).toEqual([
19+
'first',
20+
'second',
21+
]);
22+
});
23+
24+
test('exceptions in critical sections', async () => {
25+
// GIVEN
26+
const criticalSection = createCriticalSection();
27+
28+
// WHEN/THEN
29+
await expect(() => criticalSection(async () => {
30+
throw new Error('Thrown');
31+
})).rejects.toThrow('Thrown');
32+
});

0 commit comments

Comments
 (0)