UNPKG

@xec-sh/core

Version:

Universal shell execution engine

412 lines 16 kB
import { Readable } from 'node:stream'; import { spawn } from 'child_process'; import { StreamHandler } from '../utils/stream.js'; import { BaseAdapter } from './base-adapter.js'; import { TimeoutError, ExecutionError, sanitizeCommandForError } from '../core/error.js'; export class KubernetesAdapter extends BaseAdapter { constructor(config = {}) { super(config); this.adapterName = 'kubernetes'; this.portForwards = new Set(); this.name = this.adapterName; this.k8sConfig = config; this.kubectlPath = config.kubectlPath || 'kubectl'; } async isAvailable() { try { const result = await this.executeKubectl(['version', '--client'], { timeout: 5000, throwOnNonZeroExit: false, }); if (result.exitCode !== 0) { return false; } const clusterResult = await this.executeKubectl(['get', 'ns'], { timeout: 5000, throwOnNonZeroExit: false, }); return clusterResult.exitCode === 0; } catch { return false; } } async execute(command) { const mergedCommand = this.mergeCommand(command); const k8sOptions = mergedCommand.adapterOptions; if (!k8sOptions || !k8sOptions.pod) { throw new ExecutionError('Pod name or selector is required', 'KUBERNETES_ERROR'); } const kubectlArgs = await this.buildKubectlExecArgs(mergedCommand); this.emitAdapterEvent('k8s:exec', { pod: k8sOptions.pod, namespace: k8sOptions.namespace || this.k8sConfig.namespace || 'default', container: k8sOptions.container, command: this.buildCommandString(mergedCommand) }); const startTime = Date.now(); const stdoutHandler = new StreamHandler({ maxBuffer: this.config.maxBuffer, encoding: this.config.encoding, }); const stderrHandler = new StreamHandler({ maxBuffer: this.config.maxBuffer, encoding: this.config.encoding, }); return new Promise((resolve, reject) => { const env = this.createCombinedEnv(mergedCommand.env || {}); const proc = spawn(this.kubectlPath, kubectlArgs, { cwd: mergedCommand.cwd, env: { ...env, PATH: `${env['PATH'] || process.env['PATH'] || ''}:/usr/local/bin:/opt/homebrew/bin` }, shell: false, }); let timeoutHandle; if (mergedCommand.timeout) { timeoutHandle = setTimeout(() => { proc.kill(mergedCommand.timeoutSignal || 'SIGTERM'); reject(new TimeoutError(`Command timed out after ${mergedCommand.timeout}ms`, mergedCommand.timeout)); }, mergedCommand.timeout); } if (mergedCommand.stdin) { if (typeof mergedCommand.stdin === 'string' || Buffer.isBuffer(mergedCommand.stdin)) { proc.stdin.write(mergedCommand.stdin); proc.stdin.end(); } else if (mergedCommand.stdin instanceof Readable) { mergedCommand.stdin.pipe(proc.stdin); } } if (proc.stdout && mergedCommand.stdout === 'pipe') { proc.stdout.pipe(stdoutHandler.createTransform()); } if (proc.stderr && mergedCommand.stderr === 'pipe') { proc.stderr.pipe(stderrHandler.createTransform()); } proc.on('error', (error) => { if (timeoutHandle) clearTimeout(timeoutHandle); reject(new ExecutionError(`Failed to execute kubectl: ${error.message}`, 'KUBERNETES_ERROR')); }); proc.on('exit', (code, signal) => { if (timeoutHandle) clearTimeout(timeoutHandle); const endTime = Date.now(); const stdout = stdoutHandler.getContent(); const stderr = stderrHandler.getContent(); const originalThrowOnNonZeroExit = this.config.throwOnNonZeroExit; this.config.throwOnNonZeroExit = false; const result = this.createResult(stdout, stderr, code ?? -1, signal || undefined, mergedCommand.command, startTime, endTime, { originalCommand: mergedCommand }); this.config.throwOnNonZeroExit = originalThrowOnNonZeroExit; if (this.shouldThrowOnNonZeroExit(mergedCommand, code ?? -1)) { reject(new ExecutionError(`Command failed with exit code ${code}`, 'KUBERNETES_ERROR', { stdout, stderr, command: sanitizeCommandForError(kubectlArgs.join(' ')) })); } else { resolve(result); } }); }); } async buildKubectlExecArgs(command) { const k8sOptions = command.adapterOptions; const args = []; if (this.k8sConfig.kubeconfig) { args.push('--kubeconfig', this.k8sConfig.kubeconfig); } if (this.k8sConfig.context) { args.push('--context', this.k8sConfig.context); } args.push('exec'); const namespace = k8sOptions.namespace || this.k8sConfig.namespace || 'default'; args.push('-n', namespace); if (k8sOptions.tty) { args.push('-t'); args.push('-i'); } else if (k8sOptions.stdin !== false || command.stdin) { args.push('-i'); } if (k8sOptions.container) { args.push('-c', k8sOptions.container); } if (k8sOptions.execFlags) { args.push(...k8sOptions.execFlags); } let podName = k8sOptions.pod; if (k8sOptions.pod.startsWith('-l')) { const selector = k8sOptions.pod.substring(2).trim(); const selectedPod = await this.getPodFromSelector(selector, namespace); if (!selectedPod) { throw new ExecutionError(`No pod found matching selector: ${k8sOptions.pod}`, 'KUBERNETES_ERROR'); } podName = selectedPod; } args.push(podName); args.push('--'); if (command.shell) { const shellCmd = command.shell === true ? '/bin/sh' : command.shell; args.push(shellCmd, '-c', this.buildCommandString(command)); } else { args.push(...this.buildCommandArray(command)); } return args; } buildCommandArray(command) { if (Array.isArray(command.command)) { return command.command; } const parts = []; parts.push(command.command); if (command.args && command.args.length > 0) { parts.push(...command.args); } return parts; } async executeKubectl(args, options = {}) { return new Promise((resolve, reject) => { const fullArgs = []; if (this.k8sConfig.kubeconfig) { fullArgs.push('--kubeconfig', this.k8sConfig.kubeconfig); } if (this.k8sConfig.context) { fullArgs.push('--context', this.k8sConfig.context); } fullArgs.push(...args); const proc = spawn(this.kubectlPath, fullArgs, { timeout: options.timeout, env: { ...process.env, PATH: `${process.env['PATH']}:/usr/local/bin:/opt/homebrew/bin` } }); let stdout = ''; let stderr = ''; if (proc.stdout) { proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); } if (proc.stderr) { proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); } proc.on('error', (error) => { reject(new ExecutionError(`kubectl command failed: ${error.message}`, 'KUBERNETES_ERROR')); }); proc.on('exit', (code) => { const exitCode = code ?? -1; if (options.throwOnNonZeroExit && exitCode !== 0) { reject(new ExecutionError(`kubectl command failed with exit code ${exitCode}: ${stderr}`, 'KUBERNETES_ERROR', { stdout, stderr, args })); } else { resolve({ stdout, stderr, exitCode }); } }); }); } async getPodFromSelector(selector, namespace) { const args = ['get', 'pods', '-o', 'jsonpath={.items[0].metadata.name}']; const ns = namespace || this.k8sConfig.namespace || 'default'; args.push('-n', ns); if (selector.startsWith('-l')) { args.push(selector); } else { args.push('-l', selector); } try { const result = await this.executeKubectl(args); const podName = result.stdout.trim(); return podName || null; } catch { return null; } } async isPodReady(pod, namespace) { const args = ['get', 'pod', pod, '-o', 'jsonpath={.status.conditions[?(@.type=="Ready")].status}']; const ns = namespace || this.k8sConfig.namespace || 'default'; args.push('-n', ns); try { const result = await this.executeKubectl(args, { throwOnNonZeroExit: false }); return result.stdout.trim() === 'True'; } catch { return false; } } async copyFiles(source, destination, options) { const args = ['cp']; const ns = options.namespace || this.k8sConfig.namespace || 'default'; args.push('-n', ns); if (options.container) { args.push('-c', options.container); } if (options.direction === 'to') { args.push(source, destination); } else { args.push(source, destination); } await this.executeKubectl(args, { throwOnNonZeroExit: true }); } async dispose() { await this.closeAllPortForwards(); } async portForward(pod, localPort, remotePort, options = {}) { const ns = options.namespace || this.k8sConfig.namespace || 'default'; const actualLocalPort = options.dynamicLocalPort ? 0 : localPort; const args = ['port-forward', '-n', ns]; const portMapping = actualLocalPort === 0 ? `:${remotePort}` : `${localPort}:${remotePort}`; args.push(pod, portMapping); return new KubernetesPortForward(this.kubectlPath, args, localPort, remotePort, this.buildGlobalOptions()); } async streamLogs(pod, onData, options = {}) { const ns = options.namespace || this.k8sConfig.namespace || 'default'; const args = ['logs', '-n', ns]; if (options.container) { args.push('-c', options.container); } if (options.follow) { args.push('-f'); } if (options.tail !== undefined) { args.push('--tail', String(options.tail)); } if (options.previous) { args.push('--previous'); } if (options.timestamps) { args.push('--timestamps'); } args.push(pod); const proc = spawn(this.kubectlPath, [...this.buildGlobalOptions(), ...args], { env: { ...process.env, PATH: `${process.env['PATH']}:/usr/local/bin:/opt/homebrew/bin` } }); let stopped = false; if (proc.stdout) { proc.stdout.on('data', (chunk) => { if (!stopped) { const lines = chunk.toString().split('\n').filter((line) => line.trim()); lines.forEach((line) => onData(line + '\n')); } }); } if (proc.stderr) { proc.stderr.on('data', (chunk) => { if (!stopped) { console.error('kubectl logs stderr:', chunk.toString()); } }); } proc.on('error', (error) => { console.error('kubectl logs error:', error); }); return { stop: () => { stopped = true; proc.kill(); } }; } buildGlobalOptions() { const options = []; if (this.k8sConfig.kubeconfig) { options.push('--kubeconfig', this.k8sConfig.kubeconfig); } if (this.k8sConfig.context) { options.push('--context', this.k8sConfig.context); } return options; } async closeAllPortForwards() { const closes = Array.from(this.portForwards).map(pf => pf.close()); await Promise.all(closes); this.portForwards.clear(); } } class KubernetesPortForward { constructor(kubectlPath, args, requestedLocalPort, remotePort, globalOptions) { this.kubectlPath = kubectlPath; this.args = args; this.requestedLocalPort = requestedLocalPort; this.remotePort = remotePort; this.globalOptions = globalOptions; this.proc = null; this._isOpen = false; this._localPort = requestedLocalPort; } get localPort() { return this._localPort; } get isOpen() { return this._isOpen; } async open() { if (this._isOpen) { throw new Error('Port forward is already open'); } return new Promise((resolve, reject) => { this.proc = spawn(this.kubectlPath, [...this.globalOptions, ...this.args], { env: { ...process.env, PATH: `${process.env['PATH']}:/usr/local/bin:/opt/homebrew/bin` } }); let resolved = false; if (this.proc.stdout) { this.proc.stdout.on('data', (chunk) => { const output = chunk.toString(); const portMatch = output.match(/Forwarding from (?:127\.0\.0\.1:|\[::1\]:)(\d+) -> \d+/); if (portMatch && this.requestedLocalPort === 0) { this._localPort = parseInt(portMatch[1], 10); } if (output.includes('Forwarding from') && !resolved) { resolved = true; this._isOpen = true; resolve(); } }); } if (this.proc.stderr) { this.proc.stderr.on('data', (chunk) => { const error = chunk.toString(); if (!resolved) { resolved = true; reject(new ExecutionError(`Port forward failed: ${error}`, 'KUBERNETES_ERROR')); } }); } this.proc.on('error', (error) => { if (!resolved) { resolved = true; reject(new ExecutionError(`Port forward process error: ${error.message}`, 'KUBERNETES_ERROR')); } }); this.proc.on('exit', (code) => { this._isOpen = false; if (!resolved && code !== 0) { resolved = true; reject(new ExecutionError(`Port forward exited with code ${code}`, 'KUBERNETES_ERROR')); } }); }); } async close() { if (this.proc && this._isOpen) { this.proc.kill(); this._isOpen = false; this.proc = null; } } } //# sourceMappingURL=kubernetes-adapter.js.map