UNPKG

@xec-sh/core

Version:

Universal shell execution engine

933 lines 35.3 kB
import { Readable } from 'node:stream'; import { spawn } from 'node:child_process'; import { statSync, existsSync } from 'node:fs'; import { StreamHandler } from '../utils/stream.js'; import { BaseAdapter } from './base-adapter.js'; import { ExecutionResultImpl } from '../core/result.js'; import { DockerError, AdapterError, sanitizeCommandForError } from '../core/error.js'; export class DockerAdapter extends BaseAdapter { constructor(config = {}) { super(config); this.adapterName = 'docker'; this.tempContainers = new Set(); this.name = this.adapterName; this.dockerConfig = { ...config, defaultExecOptions: { AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: false, ...config.defaultExecOptions }, autoCreate: { enabled: false, image: 'alpine:latest', autoRemove: true, ...config.autoCreate } }; } findDockerPath() { const paths = [ '/usr/local/bin/docker', '/usr/bin/docker', '/opt/homebrew/bin/docker', 'docker' ]; for (const path of paths) { try { if (path === 'docker') return path; if (existsSync(path) && statSync(path).isFile()) { return path; } } catch { } } return 'docker'; } async isAvailable() { try { const result = await this.executeDockerCommand(['version', '--format', 'json'], {}); return result.exitCode === 0; } catch (error) { return false; } } async execute(command) { const mergedCommand = this.mergeCommand(command); const dockerOptions = this.extractDockerOptions(mergedCommand); if (!dockerOptions) { throw new AdapterError(this.adapterName, 'execute', new Error('Docker container options not provided')); } const startTime = Date.now(); let containerName = dockerOptions.container; try { let result; const effectiveRunMode = this.determineRunMode(dockerOptions); if (effectiveRunMode === 'run') { if (!dockerOptions.image) { throw new AdapterError(this.adapterName, 'execute', new Error('Image must be specified for run mode')); } this.emitAdapterEvent('docker:run', { image: dockerOptions.image, container: dockerOptions.container, command: this.buildCommandString(mergedCommand) }); const runArgs = this.buildDockerRunArgs(dockerOptions, mergedCommand); result = await this.executeDockerCommand(runArgs, mergedCommand); } else { this.validateContainerName(dockerOptions.container); if (this.dockerConfig.autoCreate?.enabled && !await this.containerExists(containerName)) { containerName = await this.createTempContainer(); this.emitAdapterEvent('docker:run', { image: this.dockerConfig.autoCreate.image, container: containerName, command: 'sh' }); } if (!await this.containerExists(containerName)) { throw new DockerError(containerName, 'execute', new Error(`Container '${containerName}' not found`)); } this.emitAdapterEvent('docker:exec', { container: containerName, command: this.buildCommandString(mergedCommand) }); const dockerArgs = this.buildDockerExecArgs(containerName, dockerOptions, mergedCommand); result = await this.executeDockerCommand(dockerArgs, mergedCommand); } const endTime = Date.now(); return await this.createResult(result.stdout, result.stderr, result.exitCode, result.signal ?? undefined, this.buildCommandString(mergedCommand), startTime, endTime, { container: containerName, originalCommand: mergedCommand }); } catch (error) { if (error instanceof DockerError) { throw error; } throw new DockerError(dockerOptions.container, 'execute', error instanceof Error ? error : new Error(String(error))); } } extractDockerOptions(command) { if (command.adapterOptions?.type === 'docker') { return command.adapterOptions; } return null; } validateContainerName(containerName) { if (!containerName || containerName.trim() === '') { throw new DockerError(containerName, 'validate', new Error('Container name cannot be empty')); } const dangerousChars = /[;&|`$(){}[\]<>'"\\]/; if (dangerousChars.test(containerName)) { throw new DockerError(containerName, 'validate', new Error('Container name contains invalid characters')); } if (containerName.includes('..') || containerName.startsWith('/') || containerName.match(/^[A-Za-z]:\\/)) { throw new DockerError(containerName, 'validate', new Error('Container name contains invalid path characters')); } const validNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; if (!validNamePattern.test(containerName)) { throw new DockerError(containerName, 'validate', new Error('Container name must start with alphanumeric and contain only alphanumeric, underscore, period, or hyphen')); } } supportsTTY() { return process.stdin.isTTY && process.stdout.isTTY && process.stderr.isTTY; } getTTYSettings(dockerOptions, command) { const envSupportsTTY = this.supportsTTY(); const requestedTTY = dockerOptions.tty ?? this.dockerConfig.defaultExecOptions?.Tty ?? false; const hasStdin = !!command.stdin; if (requestedTTY && !envSupportsTTY) { console.warn('TTY requested but not available in current environment'); } return { interactive: hasStdin || (requestedTTY && envSupportsTTY), tty: requestedTTY && envSupportsTTY }; } async containerExists(container) { try { const result = await this.executeDockerCommand(['inspect', container], {}); return result.exitCode === 0; } catch { return false; } } async createTempContainer() { const containerName = `temp-ush-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const createArgs = [ 'create', '--name', containerName, '-it' ]; if (this.dockerConfig.autoCreate?.volumes) { for (const volume of this.dockerConfig.autoCreate.volumes) { createArgs.push('-v', volume); } } createArgs.push(this.dockerConfig.autoCreate.image, 'sh'); const createResult = await this.executeDockerCommand(createArgs, {}); if (createResult.exitCode !== 0) { throw new DockerError(containerName, 'create', new Error(createResult.stderr)); } const startResult = await this.executeDockerCommand(['start', containerName], {}); if (startResult.exitCode !== 0) { await this.executeDockerCommand(['rm', '-f', containerName], {}); throw new DockerError(containerName, 'start', new Error(startResult.stderr)); } this.tempContainers.add(containerName); return containerName; } determineRunMode(options) { if (options.runMode) { return options.runMode; } return options.image ? 'run' : 'exec'; } buildDockerExecArgs(container, dockerOptions, command) { const args = ['exec']; const ttySettings = this.getTTYSettings(dockerOptions, command); if (ttySettings.interactive) { args.push('-i'); } if (ttySettings.tty) { args.push('-t'); } const user = dockerOptions.user || this.dockerConfig.defaultExecOptions?.User; if (user) { args.push('-u', user); } const workdir = dockerOptions.workdir || this.dockerConfig.defaultExecOptions?.WorkingDir; if (workdir) { args.push('-w', workdir); } const defaultEnv = this.dockerConfig.defaultExecOptions?.Env || []; const envFromDefaults = {}; for (const envVar of defaultEnv) { const [key, value] = envVar.split('=', 2); if (key && value !== undefined) { envFromDefaults[key] = value; } } const envToSet = { ...this.config.defaultEnv, ...envFromDefaults, ...command.env }; for (const [key, value] of Object.entries(envToSet)) { args.push('-e', `${key}=${value}`); } if (this.dockerConfig.defaultExecOptions?.Privileged) { args.push('--privileged'); } args.push(container); if (command.shell) { args.push('sh', '-c', this.buildCommandString(command)); } else { args.push(command.command); if (command.args) { args.push(...command.args); } } return args; } buildDockerRunArgs(dockerOptions, command) { const args = ['run']; if (dockerOptions.autoRemove !== false) { args.push('--rm'); } const ttySettings = this.getTTYSettings(dockerOptions, command); if (ttySettings.interactive) { args.push('-i'); } if (ttySettings.tty) { args.push('-t'); } if (dockerOptions.user) { args.push('-u', dockerOptions.user); } if (dockerOptions.workdir) { args.push('-w', dockerOptions.workdir); } if (dockerOptions.volumes) { for (const volume of dockerOptions.volumes) { args.push('-v', volume); } } if (command.env) { for (const [key, value] of Object.entries(command.env)) { if (key && value !== undefined) { args.push('-e', `${key}=${value}`); } } } if (dockerOptions.container && dockerOptions.container !== 'ephemeral') { args.push('--name', dockerOptions.container); } if (command.shell) { const cmdString = this.buildCommandString(command); args.push('--entrypoint', 'sh'); args.push(dockerOptions.image); args.push('-c', cmdString); } else { args.push(dockerOptions.image); args.push(command.command); if (command.args) { args.push(...command.args); } } return args; } async executeDockerCommand(args, command) { const timeout = command.timeout; const stdoutHandler = new StreamHandler({ encoding: this.config.encoding, maxBuffer: this.config.maxBuffer }); const stderrHandler = new StreamHandler({ encoding: this.config.encoding, maxBuffer: this.config.maxBuffer }); const env = args[0] === 'compose' && command.env ? { ...process.env, ...command.env } : process.env; const hasTTY = args.includes('-t'); const hasInteractive = args.includes('-i'); const useInheritStdin = hasTTY && hasInteractive && process.stdin.isTTY; const dockerPath = this.findDockerPath(); const child = spawn(dockerPath, args, { env, cwd: command.cwd || process.cwd(), windowsHide: true, stdio: useInheritStdin ? ['inherit', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'] }); if (child.stdin && command.stdin) { if (typeof command.stdin === 'string' || Buffer.isBuffer(command.stdin)) { child.stdin.write(command.stdin); child.stdin.end(); } else if (command.stdin instanceof Readable) { command.stdin.pipe(child.stdin); } } if (child.stdout) { const stdoutTransform = stdoutHandler.createTransform(); child.stdout.pipe(stdoutTransform); stdoutTransform.on('data', () => { }); } if (child.stderr) { const stderrTransform = stderrHandler.createTransform(); child.stderr.pipe(stderrTransform); stderrTransform.on('data', () => { }); } return new Promise((resolve, reject) => { let timeoutId; let timedOut = false; if (timeout && timeout > 0) { timeoutId = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); setTimeout(() => { if (!child.killed) { child.kill('SIGKILL'); } }, 1000); }, timeout); } child.on('error', (error) => { if (timeoutId) clearTimeout(timeoutId); reject(error); }); child.on('exit', (code, signal) => { if (timeoutId) clearTimeout(timeoutId); if (timedOut) { reject(new Error(`Command timed out after ${timeout}ms`)); return; } const result = { stdout: stdoutHandler.getContent(), stderr: stderrHandler.getContent(), exitCode: code ?? 0, signal }; resolve(result); }); }); } async createResult(stdout, stderr, exitCode, signal, command, startTime, endTime, context) { const maskedCommand = this.maskSensitiveData(command); const maskedStdout = this.maskSensitiveData(stdout); const maskedStderr = this.maskSensitiveData(stderr); const result = new ExecutionResultImpl(maskedStdout, maskedStderr, exitCode, signal, maskedCommand, endTime - startTime, new Date(startTime), new Date(endTime), this.adapterName, context?.host, context?.container); const commandForThrowCheck = context?.originalCommand ?? command; if (this.shouldThrowOnNonZeroExit(commandForThrowCheck, exitCode)) { const container = context?.container || 'unknown'; throw new DockerError(container, 'execute', new Error(`Command failed with exit code ${exitCode}: ${sanitizeCommandForError(command)}`)); } return result; } async dispose() { if (this.dockerConfig.autoCreate?.autoRemove) { for (const container of this.tempContainers) { try { this.emitAdapterEvent('temp:cleanup', { path: container, type: 'directory' }); await this.executeDockerCommand(['rm', '-f', container], {}); } catch (error) { } } } this.tempContainers.clear(); } async listContainers(all = false) { const args = ['ps']; if (all) args.push('-a'); args.push('--format', '{{.Names}}'); const result = await this.executeDockerCommand(args, {}); if (result.exitCode !== 0) { throw new DockerError('', 'list', new Error(result.stderr)); } return result.stdout.trim().split('\n').filter(Boolean); } async createContainer(options) { const args = ['create', '--name', options.name]; if (options.volumes) { for (const volume of options.volumes) { args.push('-v', volume); } } if (options.env) { for (const [key, value] of Object.entries(options.env)) { args.push('-e', `${key}=${value}`); } } if (options.ports) { for (const port of options.ports) { args.push('-p', port); } } args.push(options.image); const result = await this.executeDockerCommand(args, {}); if (result.exitCode !== 0) { throw new DockerError(options.name, 'create', new Error(result.stderr)); } } async startContainer(container) { const result = await this.executeDockerCommand(['start', container], {}); if (result.exitCode !== 0) { throw new DockerError(container, 'start', new Error(result.stderr)); } } async runContainer(options) { const args = ['run', '-d', '--name', options.name]; if (options.volumes) { for (const volume of options.volumes) { args.push('-v', volume); } } if (options.env) { for (const [key, value] of Object.entries(options.env)) { args.push('-e', `${key}=${value}`); } } if (options.ports) { for (const port of options.ports) { args.push('-p', port); } } if (options.network) { args.push('--network', options.network); } if (options.restart) { args.push('--restart', options.restart); } if (options.workdir) { args.push('-w', options.workdir); } if (options.user) { args.push('-u', options.user); } if (options.labels) { for (const [key, value] of Object.entries(options.labels)) { args.push('--label', `${key}=${value}`); } } if (options.privileged) { args.push('--privileged'); } if (options.healthcheck) { const hc = options.healthcheck; if (Array.isArray(hc.test)) { args.push('--health-cmd', hc.test.join(' ')); } else { args.push('--health-cmd', hc.test); } if (hc.interval) args.push('--health-interval', hc.interval); if (hc.timeout) args.push('--health-timeout', hc.timeout); if (hc.retries) args.push('--health-retries', String(hc.retries)); if (hc.startPeriod) args.push('--health-start-period', hc.startPeriod); } args.push(options.image); if (options.command) { args.push(...options.command); } const result = await this.executeDockerCommand(args, {}); if (result.exitCode !== 0) { throw new DockerError(options.name, 'run', new Error(`Docker run failed: ${result.stderr || result.stdout}`)); } } async stopContainer(container) { const result = await this.executeDockerCommand(['stop', container], {}); if (result.exitCode !== 0) { throw new DockerError(container, 'stop', new Error(result.stderr)); } } async removeContainer(container, force = false) { const args = ['rm']; if (force) args.push('-f'); args.push(container); const result = await this.executeDockerCommand(args, {}); if (result.exitCode !== 0) { throw new DockerError(container, 'remove', new Error(result.stderr)); } } async buildImage(options) { const args = ['build']; if (options.tag) { args.push('-t', options.tag); } if (options.dockerfile) { args.push('-f', options.dockerfile); } if (options.buildArgs) { for (const [key, value] of Object.entries(options.buildArgs)) { args.push('--build-arg', `${key}=${value}`); } } if (options.target) { args.push('--target', options.target); } if (options.noCache) { args.push('--no-cache'); } if (options.pull) { args.push('--pull'); } if (options.platform) { args.push('--platform', options.platform); } args.push('.'); const result = await this.executeDockerCommand(args, { cwd: options.context || process.cwd() }); if (result.exitCode !== 0) { throw new DockerError('', 'build', new Error(result.stderr)); } } async pushImage(image) { const result = await this.executeDockerCommand(['push', image], {}); if (result.exitCode !== 0) { throw new DockerError(image, 'push', new Error(result.stderr)); } } async pullImage(image) { const result = await this.executeDockerCommand(['pull', image], {}); if (result.exitCode !== 0) { throw new DockerError(image, 'pull', new Error(result.stderr)); } } async tagImage(source, target) { const result = await this.executeDockerCommand(['tag', source, target], {}); if (result.exitCode !== 0) { throw new DockerError(source, 'tag', new Error(result.stderr)); } } async listImages(filter) { const args = ['images', '--format', '{{.Repository}}:{{.Tag}}']; if (filter) { if (!filter.includes('=')) { args.push('--filter', `reference=${filter}*`); } else { args.push('--filter', filter); } } const result = await this.executeDockerCommand(args, {}); if (result.exitCode !== 0) { throw new DockerError('', 'images', new Error(result.stderr)); } return result.stdout.trim().split('\n').filter(Boolean); } async removeImage(image, force = false) { const args = ['rmi']; if (force) args.push('-f'); args.push(image); const result = await this.executeDockerCommand(args, {}); if (result.exitCode !== 0) { throw new DockerError(image, 'rmi', new Error(result.stderr)); } } async getLogs(container, options = {}) { const args = ['logs']; if (options.follow) { args.push('-f'); } if (options.tail !== undefined) { args.push('--tail', String(options.tail)); } if (options.since) { args.push('--since', options.since); } if (options.until) { args.push('--until', options.until); } if (options.timestamps) { args.push('-t'); } args.push(container); const result = await this.executeDockerCommand(args, {}); if (result.exitCode !== 0) { throw new DockerError(container, 'logs', new Error(result.stderr)); } return result.stdout; } async streamLogs(container, onData, options = {}) { const args = ['logs']; if (options.follow) { args.push('-f'); } if (options.tail !== undefined) { args.push('--tail', String(options.tail)); } if (options.timestamps) { args.push('-t'); } args.push(container); return new Promise((resolve, reject) => { const child = spawn('docker', args); let buffer = ''; let resolved = false; const cleanup = () => { if (!child.killed) { child.kill('SIGTERM'); } }; const safeOnData = (data) => { try { onData(data); } catch (error) { if (!resolved) { resolved = true; cleanup(); reject(new DockerError(container, 'logs', error instanceof Error ? error : new Error(String(error)))); } } }; const processData = (data) => { try { buffer += data; const lines = buffer.split('\n'); buffer = lines.pop() || ''; lines.forEach(line => { if (line && !resolved) { safeOnData(line + '\n'); } }); } catch (error) { if (!resolved) { resolved = true; cleanup(); reject(new DockerError(container, 'logs', error instanceof Error ? error : new Error(String(error)))); } } }; child.stdout?.on('data', (chunk) => { if (!resolved) { processData(chunk.toString()); } }); child.stderr?.on('data', (chunk) => { if (!resolved) { processData(chunk.toString()); } }); child.stdout?.on('error', (error) => { if (!resolved) { resolved = true; cleanup(); reject(new DockerError(container, 'logs', error)); } }); child.stderr?.on('error', (error) => { if (!resolved) { resolved = true; cleanup(); reject(new DockerError(container, 'logs', error)); } }); child.on('error', (error) => { if (!resolved) { resolved = true; cleanup(); reject(new DockerError(container, 'logs', error)); } }); child.on('exit', (code) => { if (!resolved) { resolved = true; try { if (buffer) { safeOnData(buffer); } if (code === 0 || code === 143) { resolve(); } else { reject(new DockerError(container, 'logs', new Error('Log streaming failed'))); } } catch (error) { reject(new DockerError(container, 'logs', error instanceof Error ? error : new Error(String(error)))); } } }); }); } async copyToContainer(src, container, dest) { const result = await this.executeDockerCommand(['cp', src, `${container}:${dest}`], {}); if (result.exitCode !== 0) { throw new DockerError(container, 'cp', new Error(result.stderr)); } } async copyFromContainer(container, src, dest) { const result = await this.executeDockerCommand(['cp', `${container}:${src}`, dest], {}); if (result.exitCode !== 0) { throw new DockerError(container, 'cp', new Error(result.stderr)); } } async inspectContainer(container) { const result = await this.executeDockerCommand(['inspect', container], {}); if (result.exitCode !== 0) { throw new DockerError(container, 'inspect', new Error(result.stderr)); } return JSON.parse(result.stdout)[0]; } async getStats(container) { const result = await this.executeDockerCommand([ 'stats', '--no-stream', '--format', 'json', container ], {}); if (result.exitCode !== 0) { throw new DockerError(container, 'stats', new Error(result.stderr)); } return JSON.parse(result.stdout); } async createNetwork(name, options = {}) { const existingNetworks = await this.listNetworks(); if (existingNetworks.includes(name)) { return; } const args = ['network', 'create']; if (options.driver) { args.push('--driver', options.driver); } if (options.subnet) { args.push('--subnet', options.subnet); } if (options.gateway) { args.push('--gateway', options.gateway); } if (options.ipRange) { args.push('--ip-range', options.ipRange); } if (options.attachable) { args.push('--attachable'); } if (options.internal) { args.push('--internal'); } args.push(name); const result = await this.executeDockerCommand(args, {}); if (result.exitCode !== 0) { if (result.stderr.includes('already exists')) { return; } throw new DockerError(name, 'network create', new Error(result.stderr)); } } async removeNetwork(name) { const result = await this.executeDockerCommand(['network', 'rm', name], {}); if (result.exitCode !== 0) { throw new DockerError(name, 'network rm', new Error(result.stderr)); } } async listNetworks() { const result = await this.executeDockerCommand([ 'network', 'ls', '--format', '{{.Name}}' ], {}); if (result.exitCode !== 0) { throw new DockerError('', 'network ls', new Error(result.stderr)); } return result.stdout.trim().split('\n').filter(Boolean); } async createVolume(name, options = {}) { const args = ['volume', 'create']; if (options.driver) { args.push('--driver', options.driver); } if (options.driverOpts) { for (const [key, value] of Object.entries(options.driverOpts)) { args.push('--opt', `${key}=${value}`); } } if (options.labels) { for (const [key, value] of Object.entries(options.labels)) { args.push('--label', `${key}=${value}`); } } args.push(name); const result = await this.executeDockerCommand(args, {}); if (result.exitCode !== 0) { throw new DockerError(name, 'volume create', new Error(result.stderr)); } } async removeVolume(name, force = false) { const args = ['volume', 'rm']; if (force) args.push('-f'); args.push(name); const result = await this.executeDockerCommand(args, {}); if (result.exitCode !== 0) { throw new DockerError(name, 'volume rm', new Error(result.stderr)); } } async listVolumes() { const result = await this.executeDockerCommand([ 'volume', 'ls', '--format', '{{.Name}}' ], {}); if (result.exitCode !== 0) { throw new DockerError('', 'volume ls', new Error(result.stderr)); } return result.stdout.trim().split('\n').filter(Boolean); } async composeUp(options = {}) { const args = this.buildComposeArgs(options); args.push('up', '-d'); const result = await this.executeDockerCommand(args, {}); if (result.exitCode !== 0) { throw new DockerError('', 'compose up', new Error(result.stderr)); } } async composeDown(options = {}) { const args = this.buildComposeArgs(options); args.push('down'); const result = await this.executeDockerCommand(args, {}); if (result.exitCode !== 0) { throw new DockerError('', 'compose down', new Error(result.stderr)); } } async composePs(options = {}) { const args = this.buildComposeArgs(options); args.push('ps'); const result = await this.executeDockerCommand(args, {}); if (result.exitCode !== 0) { throw new DockerError('', 'compose ps', new Error(result.stderr)); } return result.stdout; } async composeLogs(service, options = {}) { const args = this.buildComposeArgs(options); args.push('logs'); if (service) { args.push(service); } const result = await this.executeDockerCommand(args, {}); if (result.exitCode !== 0) { throw new DockerError('', 'compose logs', new Error(result.stderr)); } return result.stdout; } async waitForHealthy(container, timeout = 30000) { const startTime = Date.now(); while (Date.now() - startTime < timeout) { try { const info = await this.inspectContainer(container); const health = info.State?.Health?.Status; if (health === 'healthy') { return; } else if (health === 'unhealthy') { throw new DockerError(container, 'health', new Error('Container is unhealthy')); } } catch (error) { } await new Promise(resolve => setTimeout(resolve, 1000)); } throw new DockerError(container, 'health', new Error('Timeout waiting for container to be healthy')); } async execJson(container, command) { if (!command || command.length === 0) { throw new DockerError(container, 'exec', new Error('Command array is empty')); } const [cmd, ...args] = command; if (!cmd) { throw new DockerError(container, 'exec', new Error('Command is empty')); } const result = await this.execute({ command: cmd, args, adapterOptions: { type: 'docker', container } }); if (result.exitCode !== 0) { throw new DockerError(container, 'exec', new Error(result.stderr)); } try { return JSON.parse(result.stdout); } catch (error) { throw new DockerError(container, 'exec', new Error('Failed to parse JSON output')); } } buildComposeArgs(options) { const args = ['compose']; if (options.file) { const files = Array.isArray(options.file) ? options.file : [options.file]; for (const file of files) { args.push('-f', file); } } if (options.projectName) { args.push('-p', options.projectName); } return args; } } //# sourceMappingURL=docker-adapter.js.map