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
231 changes: 106 additions & 125 deletions lib/docker-client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import * as net from 'net';
import * as fs from 'fs';
Comment thread
wmluke marked this conversation as resolved.
import * as path from 'path';
import * as os from 'os';
import * as http from 'http';
import * as tls from 'tls';
import { createConnection } from 'node:net';
import { promises as fsPromises } from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import type { Agent } from 'node:http';
import { connect as tlsConnect } from 'node:tls';
import * as types from './types/index.js';
import { HTTPClient } from './http.js';
import { SocketAgent } from './socket.js';
Expand All @@ -18,13 +18,13 @@ import {
parseDockerHost,
} from './util.js';
import type { AuthConfig, Platform } from './types/index.js';
import type { SecureContextOptions } from 'tls';
import type { SecureContextOptions } from 'node:tls';

// noinspection JSUnusedGlobalSymbols
export class DockerClient {
private api: HTTPClient;

constructor(agent: http.Agent, userAgent: string = 'docker/node-sdk') {
constructor(agent: Agent, userAgent: string = 'docker/node-sdk') {
this.api = new HTTPClient(agent, userAgent);
}

Expand All @@ -34,88 +34,76 @@ export class DockerClient {
* @param certificates Optional path to directory containing TLS certificates (ca.pem, cert.pem, key.pem) for TCP connections
* @returns Promise that resolves to a connected DockerClient instance
*/
static fromDockerHost(
static async fromDockerHost(
dockerHost: string,
certificates?: string | SecureContextOptions,
userAgent?: string,
): Promise<DockerClient> {
return new Promise((resolve, reject) => {
if (dockerHost.startsWith('unix:')) {
// Unix socket connection - use SocketAgent with socket creation function
const socketPath = dockerHost.substring(5); // Remove "unix:" prefix
if (dockerHost.startsWith('unix:')) {
// Unix socket connection - use SocketAgent with socket creation function
const socketPath = dockerHost.substring(5); // Remove "unix:" prefix

try {
const agent = new SocketAgent(() =>
net.createConnection(socketPath),
);
resolve(new DockerClient(agent, userAgent));
} catch (error) {
reject(
new Error(
`Failed to create Docker client for ${dockerHost}: ${getErrorMessage(error)}`,
),
);
}
} else if (dockerHost.startsWith('tcp:')) {
// TCP connection - use SocketAgent with TCP socket creation function
const defaultPort = certificates ? 2376 : 2375; // Default ports: 2376 for TLS, 2375 for plain
const { host, port } = parseDockerHost(dockerHost, defaultPort);

try {
let agent: SocketAgent;

if (certificates) {
if (typeof certificates === 'string') {
// Use SocketAgent with TLS socket creation function
const tlsOptions =
TLS.loadCertificates(certificates);
agent = new SocketAgent(() =>
tls.connect({ host, port, ...tlsOptions }),
);
} else {
// certificates is a SecureContextOptions type
agent = new SocketAgent(() =>
tls.connect({ host, port, ...certificates }),
);
}
try {
const agent = new SocketAgent(() =>
createConnection(socketPath),
);
return new DockerClient(agent, userAgent);
} catch (error) {
throw new Error(
`Failed to create Docker client for ${dockerHost}: ${getErrorMessage(error)}`,
);
}
} else if (dockerHost.startsWith('tcp:')) {
// TCP connection - use SocketAgent with TCP socket creation function
const defaultPort = certificates ? 2376 : 2375; // Default ports: 2376 for TLS, 2375 for plain
const { host, port } = parseDockerHost(dockerHost, defaultPort);

try {
let agent: SocketAgent;

if (certificates) {
if (typeof certificates === 'string') {
// Use SocketAgent with TLS socket creation function
const tlsOptions =
await TLS.loadCertificates(certificates);
agent = new SocketAgent(() =>
tlsConnect({ host, port, ...tlsOptions }),
);
} else {
// Use SocketAgent with plain TCP socket creation function
// certificates is a SecureContextOptions type
agent = new SocketAgent(() =>
net.createConnection({ host, port }),
tlsConnect({ host, port, ...certificates }),
);
}

resolve(new DockerClient(agent, userAgent));
} catch (error) {
reject(
new Error(
`Failed to create Docker client for ${dockerHost}: ${getErrorMessage(error)}`,
),
);
}
} else if (dockerHost.startsWith('ssh:')) {
// SSH connection - use SocketAgent with SSH socket creation function
try {
const agent = new SocketAgent(
SSH.createSocketFactory(dockerHost),
);
resolve(new DockerClient(agent, userAgent));
} catch (error) {
reject(
new Error(
`Failed to create SSH Docker client for ${dockerHost}: ${getErrorMessage(error)}`,
),
} else {
// Use SocketAgent with plain TCP socket creation function
agent = new SocketAgent(() =>
createConnection({ host, port }),
);
}
} else {
reject(
new Error(
`Unsupported Docker host format: ${dockerHost}. Must start with "unix:", "tcp:", or "ssh:"`,
),

return new DockerClient(agent, userAgent);
} catch (error) {
throw new Error(
`Failed to create Docker client for ${dockerHost}: ${getErrorMessage(error)}`,
);
return;
}
});
} else if (dockerHost.startsWith('ssh:')) {
// SSH connection - use SocketAgent with SSH socket creation function
try {
const socketFactory = await SSH.createSocketFactory(dockerHost);
const agent = new SocketAgent(socketFactory);
return new DockerClient(agent, userAgent);
} catch (error) {
throw new Error(
`Failed to create SSH Docker client for ${dockerHost}: ${getErrorMessage(error)}`,
);
}
} else {
throw new Error(
`Unsupported Docker host format: ${dockerHost}. Must start with "unix:", "tcp:", or "ssh:"`,
);
}
}

/**
Expand All @@ -136,60 +124,58 @@ export class DockerClient {
);
}

const configDir = process.env.DOCKER_CONFIG || os.homedir();
const contextsDir = path.join(configDir, '.docker', 'contexts', 'meta');
const tlsDir = path.join(configDir, '.docker', 'contexts', 'tls');
const configDir = process.env.DOCKER_CONFIG || homedir();
const contextsDir = join(configDir, '.docker', 'contexts', 'meta');
const tlsDir = join(configDir, '.docker', 'contexts', 'tls');

try {
// Read all directories in the contexts meta directory
const contextDirs = fs
.readdirSync(contextsDir, { withFileTypes: true })
const contextEntries = await fsPromises.readdir(contextsDir, {
withFileTypes: true,
});
const contextDirs = contextEntries
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name);

for (const contextDir of contextDirs) {
const metaJsonPath = path.join(
contextsDir,
contextDir,
'meta.json',
);
const metaJsonPath = join(contextsDir, contextDir, 'meta.json');

try {
if (fs.existsSync(metaJsonPath)) {
const metaContent = fs.readFileSync(
metaJsonPath,
'utf8',
);
const meta = JSON.parse(metaContent);

if (meta.Name === targetContext) {
// Found matching context, extract endpoint
if (
meta.Endpoints &&
meta.Endpoints.docker &&
meta.Endpoints.docker.Host
) {
const dockerHost = meta.Endpoints.docker.Host;
let certificates: string | undefined =
undefined;
const tls = path.join(tlsDir, contextDir);
if (fs.existsSync(tls)) {
certificates = tls;
}
return DockerClient.fromDockerHost(
dockerHost,
certificates,
userAgent,
);
} else {
throw new Error(
`Docker context '${targetContext}' found but has no valid Docker endpoint`,
);
const metaContent = await fsPromises.readFile(
metaJsonPath,
'utf8',
);
const meta = JSON.parse(metaContent);

if (meta.Name === targetContext) {
// Found matching context, extract endpoint
if (
meta.Endpoints &&
meta.Endpoints.docker &&
meta.Endpoints.docker.Host
) {
const dockerHost = meta.Endpoints.docker.Host;
let certificates: string | undefined = undefined;
const tls = join(tlsDir, contextDir);
try {
await fsPromises.access(tls);
certificates = tls;
} catch {
// TLS directory doesn't exist, certificates remain undefined
}
return DockerClient.fromDockerHost(
dockerHost,
certificates,
userAgent,
);
} else {
throw new Error(
`Docker context '${targetContext}' found but has no valid Docker endpoint`,
);
}
}
} catch (parseError) {
// Skip invalid meta.json files
// Skip invalid meta.json files or files that don't exist
}
}

Expand Down Expand Up @@ -221,15 +207,10 @@ export class DockerClient {
// Check for DOCKER_CONFIG environment variable, otherwise use default path
const configPath =
process.env.DOCKER_CONFIG ||
path.join(os.homedir(), '.docker', 'config.json');
join(homedir(), '.docker', 'config.json');

try {
if (!fs.existsSync(configPath)) {
// If no config file exists, use default context (usually unix socket)
return DockerClient.fromDockerHost('unix:/var/run/docker.sock');
}

const configContent = fs.readFileSync(configPath, 'utf8');
const configContent = await fsPromises.readFile(configPath, 'utf8');
const config = JSON.parse(configContent);

if (config.currentContext) {
Expand Down
25 changes: 13 additions & 12 deletions lib/http.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as http from 'http';
import * as stream from 'stream';
import type { Agent, IncomingMessage, RequestOptions } from 'node:http';
import { request } from 'node:http';
import type { Readable, Writable, Duplex } from 'node:stream';
import { getErrorMessage } from './util.js';

// Docker stream content type constants
Expand Down Expand Up @@ -56,7 +57,7 @@ function parseContentType(contentType?: string): {

// Function to extract error message from response body
function getErrorMessageFromResp(
res: http.IncomingMessage,
res: IncomingMessage,
body: string | undefined,
): string | undefined {
const contentType = res.headers['content-type']?.toLowerCase();
Expand All @@ -75,7 +76,7 @@ export interface HTTPResponse {
statusCode?: number;
headers: { [key: string]: string };
body?: string;
sock?: stream.Duplex;
sock?: Duplex;
}

/**
Expand All @@ -84,10 +85,10 @@ export interface HTTPResponse {
* Handles chunked transfer encoding and provides streaming response callbacks.
*/
export class HTTPClient {
private agent: http.Agent;
private agent: Agent;
private userAgent: string;

constructor(agent: http.Agent, userAgent: string) {
constructor(agent: Agent, userAgent: string) {
this.agent = agent;
this.userAgent = userAgent;
}
Expand Down Expand Up @@ -130,7 +131,7 @@ export class HTTPClient {
};

// Prepare body data and headers
let body: string | NodeJS.ReadableStream | undefined;
let body: string | Readable | undefined;
if (data) {
// Check if body is a stream
if (
Expand All @@ -139,7 +140,7 @@ export class HTTPClient {
typeof (data as any).read === 'function'
) {
// Use chunked transfer encoding for streams
body = data as stream.Readable;
body = data as Readable;
requestHeaders['Transfer-Encoding'] = 'chunked';
} else {
// Convert to JSON string for objects
Expand All @@ -150,7 +151,7 @@ export class HTTPClient {
}

// Create HTTP request options using our instance agent
const requestOptions: http.RequestOptions = {
const requestOptions: RequestOptions = {
method,
host: 'dockerhost',
path,
Expand All @@ -160,7 +161,7 @@ export class HTTPClient {

// Helper function to create response object
const createResponse = (
res: http.IncomingMessage,
res: IncomingMessage,
body?: string,
): HTTPResponse => {
const responseHeaders: { [key: string]: string } = {};
Expand All @@ -179,7 +180,7 @@ export class HTTPClient {
};
};

const req = http.request(requestOptions, (res) => {
const req = request(requestOptions, (res) => {
let responseBody = '';

// Helper function to handle response completion
Expand Down Expand Up @@ -272,7 +273,7 @@ export class HTTPClient {
req.write(body);
req.end();
} else {
const input = body as stream.Readable;
const input = body as Readable;
input.pipe(req);
}
} else {
Expand Down
Loading