Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

79 changes: 69 additions & 10 deletions lib/docker-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
}

/**
Expand Down Expand Up @@ -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<void> {
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<T>(
response: Response,
Expand Down
4 changes: 2 additions & 2 deletions lib/filter.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export class Filter {
private data: Map<string, Set<string>> = 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 {
Expand Down
5 changes: 3 additions & 2 deletions lib/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
1 change: 1 addition & 0 deletions lib/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
116 changes: 116 additions & 0 deletions test/exec.test.ts
Original file line number Diff line number Diff line change
@@ -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');
}
});