From b6488e4bae068cd92f6cac8e2e8b18860f649ac4 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 14 Oct 2025 15:37:57 +0200 Subject: [PATCH] Add support for attached mode in execStart Signed-off-by: Nicolas De Loof --- .github/workflows/release.yml | 3 +- lib/docker-client.ts | 79 ++++++++++++++++++++--- lib/filter.ts | 4 +- lib/http.ts | 5 +- lib/logs.ts | 1 + test/exec.test.ts | 116 ++++++++++++++++++++++++++++++++++ 6 files changed, 192 insertions(+), 16 deletions(-) create mode 100644 test/exec.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a569710..7a1fd77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,10 +82,9 @@ jobs: with: node-version-file: .node-version registry-url: https://npm.pkg.github.com - always-auth: 'true' + always-auth: "true" - name: Publish (internal) if: ${{ ! inputs.dryRun }} run: | npm publish --provenance - diff --git a/lib/docker-client.ts b/lib/docker-client.ts index 5bd4aa2..edadd84 100644 --- a/lib/docker-client.ts +++ b/lib/docker-client.ts @@ -7,7 +7,13 @@ import type { SecureContextOptions } from 'node:tls'; import { connect as tlsConnect } from 'node:tls'; import type { AuthConfig, BuildInfo, Platform } from './types/index.js'; import * as types from './types/index.js'; -import { APPLICATION_JSON, APPLICATION_NDJSON, HTTPClient } from './http.js'; +import { + APPLICATION_JSON, + APPLICATION_NDJSON, + DOCKER_MULTIPLEXED_STREAM, + DOCKER_RAW_STREAM, + HTTPClient, +} from './http.js'; import { SocketAgent } from './socket.js'; import { Filter } from './filter.js'; import { SSH } from './ssh.js'; @@ -426,16 +432,27 @@ export class DockerClient { `/containers/${id}/attach`, options, ); - if (response.content === 'application/vnd.docker.raw-stream') { - response.socket.pipe(stdout); - } else { - if (stderr === null) { + switch (response.content) { + case DOCKER_RAW_STREAM: + response.socket.pipe(stdout); + break; + case DOCKER_MULTIPLEXED_STREAM: + if (stderr === null) { + throw new Error( + 'stderr is required to process multiplexed stream', + ); + } + response.socket.pipe(demultiplexStream(stdout, stderr)); + break; + default: throw new Error( - 'stderr is required to process multiplexed stream', + 'Unsupported content type: ' + response.content, ); - } - response.socket.pipe(demultiplexStream(stdout, stderr)); } + return new Promise((resolve, reject) => { + response.socket.once('error', reject); + response.socket.once('close', resolve); + }); } /** @@ -1485,19 +1502,61 @@ export class DockerClient { } /** - * Starts a previously set up ex * Start an exec instance * @param id Exec instance ID + * @param stdout Optional stream to write stdout content + * @param stderr Optional stream to write stderr content * @param execStartConfig */ public async execStart( id: string, + stdout: stream.Writable | null, + stderr: stream.Writable | null, execStartConfig?: types.ExecStartConfig, ): Promise { - await this.api.post(`/exec/${id}/start`, undefined, execStartConfig); + if (execStartConfig?.Detach) { + await this.api.post(`/exec/${id}/start`, execStartConfig); + } else { + if (isWritable(stdout)) { + const response = await this.api.upgrade( + `/exec/${id}/start`, + execStartConfig, + ); + switch (response.content) { + case DOCKER_RAW_STREAM: + response.socket.pipe(stdout); + break; + case DOCKER_MULTIPLEXED_STREAM: + if (isWritable(stderr)) { + response.socket.pipe( + demultiplexStream(stdout, stderr), + ); + break; + } else { + throw new Error( + 'stderr is required to process multiplexed stream', + ); + } + default: + throw new Error( + 'Unsupported content type: ' + response.content, + ); + } + return new Promise((resolve, reject) => { + response.socket.once('error', reject); + response.socket.once('close', resolve); + }); + } else { + throw new Error('stdout is required to process stream'); + } + } } } +function isWritable(w: Writable | null): w is Writable { + return w !== null; +} + // jsonMessages processes a response stream with newline-delimited JSON message and calls the callback for each message. async function jsonMessages( response: Response, diff --git a/lib/filter.ts b/lib/filter.ts index 767e970..56a9829 100644 --- a/lib/filter.ts +++ b/lib/filter.ts @@ -1,8 +1,8 @@ export class Filter { private data: Map> = new Map(); - - set(key: string, values: string[]): void { + set(key: string, values: string[]): Filter { this.data.set(key, new Set(values)); + return this; } add(key: string, value: string): Filter { diff --git a/lib/http.ts b/lib/http.ts index c4a8d45..8ddd611 100644 --- a/lib/http.ts +++ b/lib/http.ts @@ -4,8 +4,9 @@ import { Agent, Response, fetch, upgrade } from 'undici'; import { Duplex } from 'stream'; // Docker stream content type constants -const _DOCKER_RAW_STREAM = 'application/vnd.docker.raw-stream'; -const _DOCKER_MULTIPLEXED_STREAM = 'application/vnd.docker.multiplexed-stream'; +export const DOCKER_RAW_STREAM = 'application/vnd.docker.raw-stream'; +export const DOCKER_MULTIPLEXED_STREAM = + 'application/vnd.docker.multiplexed-stream'; export const APPLICATION_JSON = 'application/json'; export const APPLICATION_NDJSON = 'application/x-ndjson'; diff --git a/lib/logs.ts b/lib/logs.ts index e5643b2..1beb278 100644 --- a/lib/logs.ts +++ b/lib/logs.ts @@ -15,6 +15,7 @@ export class Logger extends Writable { encoding: BufferEncoding, callback: (error?: Error | null) => void, ): void { + console.log(chunk.toString()); try { this.buffer += chunk.toString(); diff --git a/test/exec.test.ts b/test/exec.test.ts new file mode 100644 index 0000000..3c0d1f7 --- /dev/null +++ b/test/exec.test.ts @@ -0,0 +1,116 @@ +import { assert, test } from 'vitest'; +import { DockerClient } from '../lib/docker-client.js'; +import { Logger } from '../lib/logs.js'; + +// Test Docker Exec API functionality + +test('should execute ps command in running container and capture output', async () => { + const client = await DockerClient.fromDockerConfig(); + let containerId: string | undefined; + + try { + // Pull alpine image first + console.log(' Pulling alpine image...'); + await client.imageCreate( + (event) => { + if (event.status) console.log(` ${event.status}`); + }, + { + fromImage: 'docker.io/library/alpine', + tag: 'latest', + }, + ); + + // Create container with sleep infinity to keep it running + console.log(' Creating Alpine container with sleep infinity...'); + const createResponse = await client.containerCreate({ + Image: 'docker.io/library/alpine:latest', + Cmd: ['sleep', 'infinity'], + Labels: { + 'test.type': 'exec-test', + }, + }); + + containerId = createResponse.Id; + assert.isNotNull(containerId); + console.log(` Container created: ${containerId.substring(0, 12)}`); + + // Start the container + console.log(' Starting container...'); + await client.containerStart(containerId); + console.log(' Container started'); + + // Create exec instance for 'ps' command + console.log(' Creating exec instance for ps command...'); + const execResponse = await client.containerExec(containerId, { + AttachStdout: true, + AttachStderr: true, + Cmd: ['ps'], + }); + + const execId = execResponse.Id; + assert.isNotNull(execId); + console.log(` Exec instance created: ${execId.substring(0, 12)}`); + + // Set up streams to capture output + const stdoutData: string[] = []; + const stderrData: string[] = []; + + const stdoutLogger = new Logger((line: string) => { + stdoutData.push(line); + }); + + const stderrLogger = new Logger((line: string) => { + stderrData.push(line); + }); + + // Start exec instance with stream capture + console.log(' Starting exec instance...'); + await client.execStart(execId, stdoutLogger, stderrLogger); + console.log(' Exec completed'); + + // Verify the output + console.log(' Verifying output...'); + console.log(` Captured stdout data: ${JSON.stringify(stdoutData)}`); + console.log(` Captured stderr data: ${JSON.stringify(stderrData)}`); + + // Check that we received process information in stdout + const allStdout = stdoutData.join('\n'); + assert.include( + allStdout, + 'sleep', + 'Should find sleep process in ps output', + ); + + // Inspect the exec instance to verify it completed successfully + console.log(' Inspecting exec instance...'); + const execInfo = await client.execInspect(execId); + console.log(` Exec exit code: ${execInfo.ExitCode}`); + + assert.equal(execInfo.ExitCode, 0, 'Exec should complete successfully'); + assert.equal( + execInfo.Running, + false, + 'Exec should not be running anymore', + ); + + console.log(' ✓ Test passed: exec lifecycle completed successfully'); + } finally { + // Clean up: delete container + if (containerId) { + console.log(' Cleaning up container...'); + try { + await client.containerDelete(containerId, { force: true }); + console.log(' Container deleted'); + } catch (error) { + console.warn( + ` Warning: Failed to delete container: ${error}`, + ); + } + } + + // Close client connection + await client.close(); + console.log(' Client connection closed'); + } +});