@xec-sh/core
Version:
Universal shell execution engine
412 lines • 16 kB
JavaScript
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