Skip to content

Commit 96e20ce

Browse files
committed
Add support for attached mode in execStart
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
1 parent 416e5b7 commit 96e20ce

5 files changed

Lines changed: 181 additions & 16 deletions

File tree

lib/docker-client.ts

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ import type { SecureContextOptions } from 'node:tls';
77
import { connect as tlsConnect } from 'node:tls';
88
import type { AuthConfig, BuildInfo, Platform } from './types/index.js';
99
import * as types from './types/index.js';
10-
import { APPLICATION_JSON, APPLICATION_NDJSON, HTTPClient } from './http.js';
10+
import {
11+
APPLICATION_JSON,
12+
APPLICATION_NDJSON,
13+
DOCKER_MULTIPLEXED_STREAM,
14+
DOCKER_RAW_STREAM,
15+
HTTPClient,
16+
} from './http.js';
1117
import { SocketAgent } from './socket.js';
1218
import { Filter } from './filter.js';
1319
import { SSH } from './ssh.js';
@@ -426,16 +432,26 @@ export class DockerClient {
426432
`/containers/${id}/attach`,
427433
options,
428434
);
429-
if (response.content === 'application/vnd.docker.raw-stream') {
430-
response.socket.pipe(stdout);
431-
} else {
432-
if (stderr === null) {
433-
throw new Error(
434-
'stderr is required to process multiplexed stream',
435-
);
436-
}
437-
response.socket.pipe(demultiplexStream(stdout, stderr));
435+
switch (response.content) {
436+
case DOCKER_RAW_STREAM:
437+
response.socket.pipe(stdout);
438+
break;
439+
case DOCKER_MULTIPLEXED_STREAM:
440+
if (stderr === null) {
441+
throw new Error(
442+
'stderr is required to process multiplexed stream',
443+
);
444+
}
445+
response.socket.pipe(demultiplexStream(stdout, stderr));
446+
break;
447+
default:
448+
throw new Error('Unsupported content type: ' + response.content);
438449
}
450+
return new Promise((resolve, reject) => {
451+
response.socket.once('error', reject);
452+
response.socket.once('close', resolve);
453+
});
454+
439455
}
440456

441457
/**
@@ -1485,19 +1501,61 @@ export class DockerClient {
14851501
}
14861502

14871503
/**
1488-
* Starts a previously set up ex
14891504
* Start an exec instance
14901505
* @param id Exec instance ID
1506+
* @param stdout Optional stream to write stdout content
1507+
* @param stderr Optional stream to write stderr content
14911508
* @param execStartConfig
14921509
*/
14931510
public async execStart(
14941511
id: string,
1512+
stdout: stream.Writable | null,
1513+
stderr: stream.Writable | null,
14951514
execStartConfig?: types.ExecStartConfig,
14961515
): Promise<void> {
1497-
await this.api.post(`/exec/${id}/start`, undefined, execStartConfig);
1516+
if (execStartConfig?.Detach) {
1517+
await this.api.post(`/exec/${id}/start`, execStartConfig);
1518+
} else {
1519+
if (isWritable(stdout)) {
1520+
const response = await this.api.upgrade(
1521+
`/exec/${id}/start`,
1522+
execStartConfig,
1523+
);
1524+
switch (response.content) {
1525+
case DOCKER_RAW_STREAM:
1526+
response.socket.pipe(stdout);
1527+
break
1528+
case DOCKER_MULTIPLEXED_STREAM:
1529+
if (isWritable(stderr)) {
1530+
response.socket.pipe(
1531+
demultiplexStream(stdout, stderr),
1532+
);
1533+
break
1534+
} else {
1535+
throw new Error(
1536+
'stderr is required to process multiplexed stream',
1537+
);
1538+
}
1539+
default:
1540+
throw new Error(
1541+
'Unsupported content type: ' + response.content,
1542+
);
1543+
}
1544+
return new Promise((resolve, reject) => {
1545+
response.socket.once('error', reject);
1546+
response.socket.once('close', resolve);
1547+
});
1548+
} else {
1549+
throw new Error('stdout is required to process stream');
1550+
}
1551+
}
14981552
}
14991553
}
15001554

1555+
function isWritable(w: Writable | null): w is Writable {
1556+
return w !== null;
1557+
}
1558+
15011559
// jsonMessages processes a response stream with newline-delimited JSON message and calls the callback for each message.
15021560
async function jsonMessages<T>(
15031561
response: Response,

lib/filter.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
export class Filter {
22
private data: Map<string, Set<string>> = new Map();
3-
4-
set(key: string, values: string[]): void {
3+
set(key: string, values: string[]): Filter {
54
this.data.set(key, new Set(values));
5+
return this;
66
}
77

88
add(key: string, value: string): Filter {

lib/http.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { Agent, Response, fetch, upgrade } from 'undici';
44
import { Duplex } from 'stream';
55

66
// Docker stream content type constants
7-
const _DOCKER_RAW_STREAM = 'application/vnd.docker.raw-stream';
8-
const _DOCKER_MULTIPLEXED_STREAM = 'application/vnd.docker.multiplexed-stream';
7+
export const DOCKER_RAW_STREAM = 'application/vnd.docker.raw-stream';
8+
export const DOCKER_MULTIPLEXED_STREAM = 'application/vnd.docker.multiplexed-stream';
99
export const APPLICATION_JSON = 'application/json';
1010
export const APPLICATION_NDJSON = 'application/x-ndjson';
1111

lib/logs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export class Logger extends Writable {
1515
encoding: BufferEncoding,
1616
callback: (error?: Error | null) => void,
1717
): void {
18+
console.log(chunk.toString());
1819
try {
1920
this.buffer += chunk.toString();
2021

test/exec.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { assert, test } from 'vitest';
2+
import { DockerClient } from '../lib/docker-client.js';
3+
import { Logger } from '../lib/logs.js';
4+
5+
// Test Docker Exec API functionality
6+
7+
test('should execute ps command in running container and capture output', async () => {
8+
const client = await DockerClient.fromDockerConfig();
9+
let containerId: string | undefined;
10+
11+
try {
12+
// Pull alpine image first
13+
console.log(' Pulling alpine image...');
14+
await client.imageCreate(
15+
(event) => {
16+
if (event.status) console.log(` ${event.status}`);
17+
},
18+
{
19+
fromImage: 'docker.io/library/alpine',
20+
tag: 'latest',
21+
},
22+
);
23+
24+
// Create container with sleep infinity to keep it running
25+
console.log(' Creating Alpine container with sleep infinity...');
26+
const createResponse = await client.containerCreate({
27+
Image: 'docker.io/library/alpine:latest',
28+
Cmd: ['sleep', 'infinity'],
29+
Labels: {
30+
'test.type': 'exec-test',
31+
},
32+
});
33+
34+
containerId = createResponse.Id;
35+
assert.isNotNull(containerId);
36+
console.log(` Container created: ${containerId.substring(0, 12)}`);
37+
38+
// Start the container
39+
console.log(' Starting container...');
40+
await client.containerStart(containerId);
41+
console.log(' Container started');
42+
43+
// Create exec instance for 'ps' command
44+
console.log(' Creating exec instance for ps command...');
45+
const execResponse = await client.containerExec(containerId, {
46+
AttachStdout: true,
47+
AttachStderr: true,
48+
Cmd: ['ps'],
49+
});
50+
51+
const execId = execResponse.Id;
52+
assert.isNotNull(execId);
53+
console.log(` Exec instance created: ${execId.substring(0, 12)}`);
54+
55+
// Set up streams to capture output
56+
const stdoutData: string[] = [];
57+
const stderrData: string[] = [];
58+
59+
const stdoutLogger = new Logger((line: string) => {
60+
stdoutData.push(line);
61+
});
62+
63+
const stderrLogger = new Logger((line: string) => {
64+
stderrData.push(line);
65+
});
66+
67+
// Start exec instance with stream capture
68+
console.log(' Starting exec instance...');
69+
await client.execStart(execId, stdoutLogger, stderrLogger);
70+
console.log(' Exec completed');
71+
72+
// Verify the output
73+
console.log(' Verifying output...');
74+
console.log(` Captured stdout data: ${JSON.stringify(stdoutData)}`);
75+
console.log(` Captured stderr data: ${JSON.stringify(stderrData)}`);
76+
77+
// Check that we received process information in stdout
78+
const allStdout = stdoutData.join('\n');
79+
assert.include(allStdout, 'sleep', 'Should find sleep process in ps output');
80+
81+
// Inspect the exec instance to verify it completed successfully
82+
console.log(' Inspecting exec instance...');
83+
const execInfo = await client.execInspect(execId);
84+
console.log(` Exec exit code: ${execInfo.ExitCode}`);
85+
86+
assert.equal(execInfo.ExitCode, 0, 'Exec should complete successfully');
87+
assert.equal(execInfo.Running, false, 'Exec should not be running anymore');
88+
89+
console.log(' ✓ Test passed: exec lifecycle completed successfully');
90+
} finally {
91+
// Clean up: delete container
92+
if (containerId) {
93+
console.log(' Cleaning up container...');
94+
try {
95+
await client.containerDelete(containerId, { force: true });
96+
console.log(' Container deleted');
97+
} catch (error) {
98+
console.warn(` Warning: Failed to delete container: ${error}`);
99+
}
100+
}
101+
102+
// Close client connection
103+
await client.close();
104+
console.log(' Client connection closed');
105+
}
106+
});

0 commit comments

Comments
 (0)