UNPKG

@xec-sh/core

Version:

Universal shell execution engine

300 lines 11.9 kB
import { Readable } from 'node:stream'; import { Client } from 'ssh2'; import { StreamHandler } from '../utils/stream.js'; import { BaseAdapter } from './base-adapter.js'; import { DockerError, AdapterError, TimeoutError, ConnectionError } from '../core/error.js'; export class RemoteDockerAdapter extends BaseAdapter { constructor(config) { super(config); this.adapterName = 'remote-docker'; this.sshClient = null; this.tempContainers = new Set(); this.name = this.adapterName; this.remoteDockerConfig = { ...config, dockerPath: config.dockerPath || 'docker', autoCreate: { enabled: false, image: 'alpine:latest', autoRemove: true, ...config.autoCreate } }; } async isAvailable() { try { const client = await this.getConnection(); const result = await this.executeSSHCommand(client, `${this.remoteDockerConfig.dockerPath} version --format json`); return result.exitCode === 0; } catch { return false; } } async execute(command) { const mergedCommand = this.mergeCommand(command); const remoteDockerOptions = this.extractRemoteDockerOptions(mergedCommand); if (!remoteDockerOptions) { throw new AdapterError(this.adapterName, 'execute', new Error('Remote Docker options not provided')); } const startTime = Date.now(); try { const client = await this.getConnection(); let container = remoteDockerOptions.docker.container; if (this.remoteDockerConfig.autoCreate?.enabled) { const exists = await this.containerExists(client, container); if (!exists) { container = await this.createTempContainer(client); } } const dockerCmd = this.buildDockerExecCommand(container, remoteDockerOptions.docker, mergedCommand); const result = await this.executeSSHCommand(client, dockerCmd, mergedCommand.stdin, mergedCommand.timeout, mergedCommand.signal); const endTime = Date.now(); return this.createResult(result.stdout, result.stderr, result.exitCode, result.signal, mergedCommand.command, startTime, endTime, { host: this.remoteDockerConfig.ssh.host, container }); } catch (error) { if (error instanceof Error) { throw new DockerError(remoteDockerOptions.docker.container, 'remote-exec', error); } throw error; } } extractRemoteDockerOptions(command) { if (command.adapterOptions?.type === 'remote-docker') { return command.adapterOptions; } if (command.adapterOptions?.type === 'ssh' && command.adapterOptions.docker) { const sshOpts = command.adapterOptions; return { type: 'remote-docker', ssh: sshOpts, docker: sshOpts.docker }; } if (this.remoteDockerConfig.ssh && command.adapterOptions?.type === 'docker') { return { type: 'remote-docker', ssh: this.remoteDockerConfig.ssh, docker: command.adapterOptions }; } return null; } buildDockerExecCommand(container, dockerOptions, command) { const args = [this.remoteDockerConfig.dockerPath, 'exec']; if (command.stdin) { args.push('-i'); } if (dockerOptions.tty ?? false) { args.push('-t'); } if (dockerOptions.user) { args.push('-u', dockerOptions.user); } if (dockerOptions.workdir || command.cwd) { args.push('-w', dockerOptions.workdir || command.cwd); } if (command.env) { for (const [key, value] of Object.entries(command.env)) { args.push('-e', `${key}=${value}`); } } args.push(container); if (command.shell) { const shellCmd = typeof command.shell === 'string' ? command.shell : '/bin/sh'; args.push(shellCmd, '-c', this.buildCommandString(command)); } else { args.push(command.command); if (command.args) { args.push(...command.args); } } return args.map(arg => { if (arg.includes(' ') || arg.includes('"') || arg.includes("'") || arg.includes('$')) { return `"${arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$')}"`; } return arg; }).join(' '); } async containerExists(client, container) { try { const result = await this.executeSSHCommand(client, `${this.remoteDockerConfig.dockerPath} inspect -f '{{.State.Running}}' ${container}`); return result.exitCode === 0; } catch { return false; } } async createTempContainer(client) { const containerName = `xec-temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const createArgs = [ this.remoteDockerConfig.dockerPath, 'run', '-d', '--name', containerName ]; if (this.remoteDockerConfig.autoCreate.autoRemove) { createArgs.push('--rm'); } if (this.remoteDockerConfig.autoCreate?.volumes) { for (const volume of this.remoteDockerConfig.autoCreate.volumes) { createArgs.push('-v', volume); } } createArgs.push(this.remoteDockerConfig.autoCreate.image, 'tail', '-f', '/dev/null'); const result = await this.executeSSHCommand(client, createArgs.join(' ')); if (result.exitCode !== 0) { throw new DockerError(containerName, 'create', new Error(result.stderr)); } this.tempContainers.add(containerName); return containerName; } async getConnection() { if (this.sshClient) { try { await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Connection test timeout')), 1000); this.sshClient.exec('echo test', (err) => { clearTimeout(timeout); if (err) reject(err); else resolve(); }); }); return this.sshClient; } catch { this.sshClient.end(); this.sshClient = null; } } return new Promise((resolve, reject) => { const client = new Client(); const connectConfig = { host: this.remoteDockerConfig.ssh.host, port: this.remoteDockerConfig.ssh.port || 22, username: this.remoteDockerConfig.ssh.username, privateKey: this.remoteDockerConfig.ssh.privateKey, passphrase: this.remoteDockerConfig.ssh.passphrase, password: this.remoteDockerConfig.ssh.password, readyTimeout: this.remoteDockerConfig.ssh.readyTimeout || 20000, keepaliveInterval: this.remoteDockerConfig.ssh.keepaliveInterval || 10000, keepaliveCountMax: this.remoteDockerConfig.ssh.keepaliveCountMax || 3 }; const timeout = setTimeout(() => { client.destroy(); reject(new ConnectionError(this.remoteDockerConfig.ssh.host, new Error('Connection timeout'))); }, connectConfig.readyTimeout); client.once('ready', () => { clearTimeout(timeout); this.sshClient = client; resolve(client); }); client.once('error', (err) => { clearTimeout(timeout); reject(new ConnectionError(this.remoteDockerConfig.ssh.host, err)); }); client.connect(connectConfig); }); } async executeSSHCommand(client, command, stdin, timeout, signal) { return new Promise((resolve, reject) => { const stdoutHandler = new StreamHandler({ maxBuffer: this.config.maxBuffer, encoding: this.config.encoding }); const stderrHandler = new StreamHandler({ maxBuffer: this.config.maxBuffer, encoding: this.config.encoding }); let timeoutHandle; let abortHandler; const cleanup = () => { if (timeoutHandle) clearTimeout(timeoutHandle); if (signal && abortHandler) { signal.removeEventListener('abort', abortHandler); } }; client.exec(command, (err, stream) => { if (err) { cleanup(); reject(err); return; } if (timeout) { timeoutHandle = setTimeout(() => { stream.destroy(); cleanup(); reject(new TimeoutError(command, timeout)); }, timeout); } if (signal) { if (signal.aborted) { stream.destroy(); cleanup(); reject(new AdapterError(this.adapterName, 'execute', new Error('Operation aborted'))); return; } abortHandler = () => { stream.destroy(); cleanup(); }; signal.addEventListener('abort', abortHandler, { once: true }); } if (stdin) { if (typeof stdin === 'string' || Buffer.isBuffer(stdin)) { stream.write(stdin); stream.end(); } else if (stdin instanceof Readable) { stdin.pipe(stream); } } else { stream.end(); } stream.pipe(stdoutHandler.createTransform()); stream.stderr.pipe(stderrHandler.createTransform()); stream.on('close', (code, signalName) => { cleanup(); const stdout = stdoutHandler.getContent(); const stderr = stderrHandler.getContent(); resolve({ stdout, stderr, exitCode: code ?? -1, signal: signalName }); }); stream.on('error', (error) => { cleanup(); reject(error); }); }); }); } async dispose() { if (this.sshClient && this.tempContainers.size > 0) { for (const container of this.tempContainers) { try { await this.executeSSHCommand(this.sshClient, `${this.remoteDockerConfig.dockerPath} stop ${container}`); } catch { } } this.tempContainers.clear(); } if (this.sshClient) { this.sshClient.end(); this.sshClient = null; } } } export function createRemoteDockerAdapter(sshOptions, dockerOptions) { return new RemoteDockerAdapter({ ssh: sshOptions, ...dockerOptions }); } //# sourceMappingURL=remote-docker-adapter.js.map