Skip to content

Commit ba20205

Browse files
committed
introduce JSONMessage to consumer JSON stream or just wait for completion
Signed-off-by: Nicolas De Loof <[email protected]>
1 parent ae4c33b commit ba20205

File tree

5 files changed

+151
-67
lines changed

5 files changed

+151
-67
lines changed

lib/docker-client.ts

Lines changed: 106 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { homedir } from 'node:os';
55
import type { Agent } from 'undici';
66
import type { SecureContextOptions } from 'node:tls';
77
import { connect as tlsConnect } from 'node:tls';
8-
import type { AuthConfig, Platform } from './types/index.js';
8+
import type { AuthConfig, JSONMessage, Platform } from './types/index.js';
99
import * as types from './types/index.js';
1010
import {
1111
APPLICATION_JSON,
@@ -499,6 +499,8 @@ export class DockerClient {
499499
platform?: string;
500500
},
501501
): Promise<types.ContainerCreateResponse> {
502+
spec.Image = this.parseDockerRef(spec.Image);
503+
502504
const response = await this.api.post(
503505
'/containers/create',
504506
options,
@@ -1089,7 +1091,7 @@ export class DockerClient {
10891091
* @param options.outputs BuildKit output configuration in the format of a stringified JSON array of objects. Each object must have two top-level properties: &#x60;Type&#x60; and &#x60;Attrs&#x60;. The &#x60;Type&#x60; property must be set to \&#39;moby\&#39;. The &#x60;Attrs&#x60; property is a map of attributes for the BuildKit output configuration. See https://docs.docker.com/build/exporters/oci-docker/ for more information. Example: &#x60;&#x60;&#x60; [{\&quot;Type\&quot;:\&quot;moby\&quot;,\&quot;Attrs\&quot;:{\&quot;type\&quot;:\&quot;image\&quot;,\&quot;force-compression\&quot;:\&quot;true\&quot;,\&quot;compression\&quot;:\&quot;zstd\&quot;}}] &#x60;&#x60;&#x60;
10901092
* @param options.version Version of the builder backend to use. - &#x60;1&#x60; is the first generation classic (deprecated) builder in the Docker daemon (default) - &#x60;2&#x60; is [BuildKit](https://github.com/moby/buildkit)
10911093
*/
1092-
public async *imageBuild(
1094+
public imageBuild(
10931095
buildContext: ReadableStream,
10941096
options?: {
10951097
dockerfile?: string;
@@ -1119,7 +1121,7 @@ export class DockerClient {
11191121
outputs?: string;
11201122
version?: '1' | '2';
11211123
},
1122-
): AsyncGenerator<types.JSONMessage, void, undefined> {
1124+
): JSONMessages<JSONMessage, string> {
11231125
const headers: Record<string, string> = {};
11241126
headers['Content-Type'] = 'application/x-tar';
11251127

@@ -1129,7 +1131,7 @@ export class DockerClient {
11291131
);
11301132
}
11311133

1132-
const response = await this.api.post(
1134+
const request = this.api.post(
11331135
'/build',
11341136
{
11351137
dockerfile: options?.dockerfile,
@@ -1162,7 +1164,31 @@ export class DockerClient {
11621164
headers,
11631165
);
11641166

1165-
yield* jsonMessages<types.JSONMessage>(response);
1167+
return {
1168+
messages: async function* (): AsyncGenerator<
1169+
types.JSONMessage,
1170+
void,
1171+
undefined
1172+
> {
1173+
const response = await request;
1174+
yield* jsonMessages<types.JSONMessage>(response);
1175+
},
1176+
wait: async function (): Promise<string> {
1177+
let id = '';
1178+
const response = await request;
1179+
for await (const message of jsonMessages<types.JSONMessage>(
1180+
response,
1181+
)) {
1182+
if (message.errorDetail) {
1183+
throw new Error(message.errorDetail?.message);
1184+
}
1185+
if (message.id === 'moby.image.id') {
1186+
id = message?.aux?.ID || '';
1187+
}
1188+
}
1189+
return id;
1190+
},
1191+
};
11661192
}
11671193

11681194
/**
@@ -1215,7 +1241,7 @@ export class DockerClient {
12151241
* @param options.platform Platform in the format os[/arch[/variant]]. When used in combination with the 'fromImage' option, the daemon checks if the given image is present in the local image cache with the given OS and Architecture, and otherwise attempts to pull the image. If the option is not set, the host\&#39;s native OS and Architecture are used. If the given image does not exist in the local image cache, the daemon attempts to pull the image with the host\&#39;s native OS and Architecture. If the given image does exists in the local image cache, but its OS or architecture does not match, a warning is produced. When used with the 'fromSrc' option to import an image from an archive, this option sets the platform information for the imported image. If the option is not set, the host\&#39;s native OS and Architecture are used for the imported image.
12161242
* @param options.inputImage Image content if the value '-' has been specified in fromSrc query parameter
12171243
*/
1218-
public async *imageCreate(options?: {
1244+
public imageCreate(options?: {
12191245
fromImage?: string;
12201246
fromSrc?: string;
12211247
repo?: string;
@@ -1225,7 +1251,7 @@ export class DockerClient {
12251251
changes?: Array<string>;
12261252
platform?: string;
12271253
inputImage?: string;
1228-
}): AsyncGenerator<types.JSONMessage, void, undefined> {
1254+
}): JSONMessages<JSONMessage, string> {
12291255
const headers: Record<string, string> = {};
12301256

12311257
if (options?.credentials) {
@@ -1234,10 +1260,12 @@ export class DockerClient {
12341260
);
12351261
}
12361262

1237-
const response = await this.api.post(
1263+
let ref = this.parseDockerRef(options?.fromImage);
1264+
1265+
const request = this.api.post(
12381266
'/images/create',
12391267
{
1240-
fromImage: options?.fromImage,
1268+
fromImage: ref,
12411269
fromSrc: options?.fromSrc,
12421270
repo: options?.repo,
12431271
tag: options?.tag,
@@ -1249,7 +1277,45 @@ export class DockerClient {
12491277
undefined,
12501278
headers,
12511279
);
1252-
yield* jsonMessages<types.JSONMessage>(response);
1280+
1281+
return {
1282+
messages: async function* (): AsyncGenerator<
1283+
types.JSONMessage,
1284+
void,
1285+
undefined
1286+
> {
1287+
const response = await request;
1288+
yield* jsonMessages<types.JSONMessage>(response);
1289+
},
1290+
wait: async function (): Promise<string> {
1291+
let digest = '';
1292+
const response = await request;
1293+
for await (const message of jsonMessages<types.JSONMessage>(
1294+
response,
1295+
)) {
1296+
if (message.errorDetail) {
1297+
throw new Error(message.errorDetail?.message);
1298+
}
1299+
if (message.status?.startsWith('Digest: ')) {
1300+
digest = message.status.substring(8);
1301+
}
1302+
}
1303+
return digest;
1304+
},
1305+
};
1306+
}
1307+
1308+
private parseDockerRef(ref: string | undefined) {
1309+
if (ref && !ref.includes('/')) {
1310+
ref = `docker.io/library/${ref}`;
1311+
} else if (
1312+
ref &&
1313+
ref.startsWith('docker.io/') &&
1314+
ref.split('/').length === 2
1315+
) {
1316+
ref = ref.replace('docker.io/', 'docker.io/library/');
1317+
}
1318+
return ref;
12531319
}
12541320

12551321
/**
@@ -1413,14 +1479,14 @@ export class DockerClient {
14131479
* @param options.tag Tag of the image to push. For example, &#x60;latest&#x60;. If no tag is provided, all tags of the given image that are present in the local image store are pushed.
14141480
* @param options.platform JSON-encoded OCI platform to select the platform-variant to push. If not provided, all available variants will attempt to be pushed. If the daemon provides a multi-platform image store, this selects the platform-variant to push to the registry. If the image is a single-platform image, or if the multi-platform image does not provide a variant matching the given platform, an error is returned. Example: &#x60;{\&quot;os\&quot;: \&quot;linux\&quot;, \&quot;architecture\&quot;: \&quot;arm\&quot;, \&quot;variant\&quot;: \&quot;v5\&quot;}&#x60;
14151481
*/
1416-
public async *imagePush(
1482+
public imagePush(
14171483
name: string,
14181484
options: {
14191485
credentials?: AuthConfig;
14201486
tag?: string;
14211487
platform?: Platform;
14221488
},
1423-
): AsyncGenerator<types.JSONMessage, void, undefined> {
1489+
): JSONMessages<JSONMessage, void> {
14241490
const headers: Record<string, string> = {};
14251491

14261492
if (options?.credentials) {
@@ -1429,7 +1495,7 @@ export class DockerClient {
14291495
);
14301496
}
14311497

1432-
const response = await this.api.post(
1498+
const request = this.api.post(
14331499
`/images/${name}/push`,
14341500
{
14351501
tag: options?.tag,
@@ -1438,7 +1504,28 @@ export class DockerClient {
14381504
undefined,
14391505
headers,
14401506
);
1441-
yield* jsonMessages<types.JSONMessage>(response);
1507+
1508+
return {
1509+
messages: async function* (): AsyncGenerator<
1510+
types.JSONMessage,
1511+
void,
1512+
undefined
1513+
> {
1514+
const response = await request;
1515+
yield* jsonMessages<types.JSONMessage>(response);
1516+
},
1517+
wait: async function (): Promise<void> {
1518+
const response = await request;
1519+
for await (const message of jsonMessages<types.JSONMessage>(
1520+
response,
1521+
)) {
1522+
if (message.errorDetail) {
1523+
throw new Error(message.errorDetail?.message);
1524+
}
1525+
// noop
1526+
}
1527+
},
1528+
};
14421529
}
14431530

14441531
/**
@@ -1565,3 +1652,8 @@ export class DockerClient {
15651652
function isWritable(w: Writable | null): w is Writable {
15661653
return w !== null;
15671654
}
1655+
1656+
export interface JSONMessages<T, R> {
1657+
messages(): AsyncGenerator<T, void, undefined>;
1658+
wait(): Promise<R>;
1659+
}

test/build.test.ts

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,24 +19,16 @@ COPY test.txt /test.txt
1919
pack.entry({ name: 'test.txt' }, 'Hello from Docker build test!');
2020
pack.finalize();
2121

22-
let builtImage: string | undefined;
23-
24-
for await (const buildInfo of client.imageBuild(
25-
Readable.toWeb(pack, { strategy: { highWaterMark: 16384 } }),
26-
{
27-
tag: `${testImageName}:${testTag}`,
28-
rm: true,
29-
forcerm: true,
30-
},
31-
)) {
32-
console.log(` Build event: ${JSON.stringify(buildInfo)}`);
33-
// Capture the built image ID when buildinfo.id == 'moby.image.id'
34-
if (buildInfo.id === 'moby.image.id') {
35-
builtImage = buildInfo.aux?.ID;
36-
}
37-
}
38-
39-
expect(builtImage).toBeDefined();
22+
const builtImage = await client
23+
.imageBuild(
24+
Readable.toWeb(pack, { strategy: { highWaterMark: 16384 } }),
25+
{
26+
tag: `${testImageName}:${testTag}`,
27+
rm: true,
28+
forcerm: true,
29+
},
30+
)
31+
.wait();
4032

4133
// Inspect the built builtImage to confirm it was created successfully
4234
console.log(` Inspecting built image ${builtImage}`);

test/container.test.ts

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,17 @@ test('should receive container stdout on attach', async () => {
1313
try {
1414
// Pull alpine image first
1515
console.log(' Pulling alpine image...');
16-
for await (const event of client.imageCreate({
17-
fromImage: 'docker.io/library/alpine',
18-
tag: 'latest',
19-
})) {
20-
if (event.status) console.log(` ${event.status}`);
21-
}
16+
await client
17+
.imageCreate({
18+
fromImage: 'alpine',
19+
tag: 'latest',
20+
})
21+
.wait();
2222

2323
// Create container with echo command
2424
console.log(' Creating Alpine container with echo command...');
2525
const createResponse = await client.containerCreate({
26-
Image: 'docker.io/library/alpine:latest',
26+
Image: 'alpine',
2727
Cmd: ['echo', 'hello'],
2828
Labels: {
2929
'test.type': 'container-test',
@@ -111,17 +111,17 @@ test('should collect container output using containerLogs', async () => {
111111
try {
112112
// Pull alpine image first (should be cached from previous test)
113113
console.log(' Pulling alpine image...');
114-
for await (const event of client.imageCreate({
115-
fromImage: 'docker.io/library/alpine',
116-
tag: 'latest',
117-
})) {
118-
if (event.status) console.log(` ${event.status}`);
119-
}
114+
await client
115+
.imageCreate({
116+
fromImage: 'alpine',
117+
tag: 'latest',
118+
})
119+
.wait();
120120

121121
// Create container with a command that produces multiple lines of output
122122
console.log(' Creating Alpine container with multi-line output...');
123123
const createResponse = await client.containerCreate({
124-
Image: 'docker.io/library/alpine:latest',
124+
Image: 'alpine',
125125
Cmd: ['sh', '-c', 'echo "line1"; echo "line2"; echo "line3"'],
126126
Labels: {
127127
'test.type': 'container-logs-test',
@@ -246,18 +246,18 @@ test('container lifecycle should work end-to-end', async () => {
246246
let containerId: string | undefined;
247247

248248
try {
249-
for await (const event of client.imageCreate({
250-
fromImage: 'docker.io/library/nginx',
251-
tag: 'latest',
252-
})) {
253-
console.log(event);
254-
}
249+
await client
250+
.imageCreate({
251+
fromImage: 'nginx',
252+
tag: 'latest',
253+
})
254+
.wait();
255255

256256
console.log(' Creating nginx container...');
257257
// Create container with label
258258
const createResponse = await client.containerCreate(
259259
{
260-
Image: 'docker.io/library/nginx',
260+
Image: 'nginx',
261261
Labels: {
262262
'test.type': 'e2e',
263263
},

test/exec.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,17 @@ test('should execute ps command in running container and capture output', async
1111
try {
1212
// Pull alpine image first
1313
console.log(' Pulling alpine image...');
14-
for await (const event of client.imageCreate({
15-
fromImage: 'docker.io/library/alpine',
16-
tag: 'latest',
17-
})) {
18-
if (event.status) console.log(` ${event.status}`);
19-
}
14+
await client
15+
.imageCreate({
16+
fromImage: 'alpine',
17+
tag: 'latest',
18+
})
19+
.wait();
2020

2121
// Create container with sleep infinity to keep it running
2222
console.log(' Creating Alpine container with sleep infinity...');
2323
const createResponse = await client.containerCreate({
24-
Image: 'docker.io/library/alpine:latest',
24+
Image: 'alpine',
2525
Cmd: ['sleep', 'infinity'],
2626
Labels: {
2727
'test.type': 'exec-test',

test/image.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,16 @@ test('image lifecycle: create container, commit image, export/import, inspect, a
1212
try {
1313
// Step 1: Pull alpine image and create container
1414
console.log(' Pulling alpine image...');
15-
for await (const event of client.imageCreate({
16-
fromImage: 'docker.io/library/alpine',
17-
tag: 'latest',
18-
})) {
19-
if (event.status) console.log(` ${event.status}`);
20-
}
15+
await client
16+
.imageCreate({
17+
fromImage: 'alpine',
18+
tag: 'latest',
19+
})
20+
.wait();
2121

2222
console.log(' Creating Alpine container...');
2323
const createResponse = await client.containerCreate({
24-
Image: 'docker.io/library/alpine:latest',
24+
Image: 'alpine',
2525
Cmd: ['echo', 'test container'],
2626
Labels: {
2727
'test.type': 'image-test',

0 commit comments

Comments
 (0)