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