mcp-orchestrator-server
Version:
Serveur MCP pour l'orchestration de tâches distantes (SSH/SFTP) avec une file d'attente persistante.
529 lines (460 loc) • 18.8 kB
JavaScript
import { Client } from 'ssh2';
import fs from 'fs/promises';
import queue from './queue.js';
import serverManager from './servers.js';
import sshPool from './sshPool.js';
import config from './config.js';
const STREAMING_COMMANDS = [
/^pm2\s+logs?\b/i,
/^docker\s+logs?\s+(-f|--follow)/i,
/^tail\s+-f/i,
/^journalctl\s+-f/i,
/^watch\b/i,
/^top\b/i,
/^htop\b/i,
/^less\b/i,
/^more\b/i,
/^vim?\b/i,
/^nano\b/i
];
function isStreamingCommand(cmd) {
return STREAMING_COMMANDS.some(pattern => pattern.test(cmd.trim()));
}
// Configuration des réponses automatiques pour les prompts interactifs
const INTERACTIVE_RESPONSES = {
// Patterns courants et leurs réponses
'continue connecting': 'yes',
'Are you sure': 'yes',
'password:': null, // Sera géré séparément
'Password:': null,
'Do you want to continue': 'y',
'Overwrite': 'y',
'Save': 'y'
};
// Détecte si une sortie contient un prompt interactif
function detectInteractivePrompt(output) {
const lastLine = output.split('\n').pop().toLowerCase();
for (const [pattern, response] of Object.entries(INTERACTIVE_RESPONSES)) {
if (lastLine.includes(pattern.toLowerCase())) {
return { pattern, response, needsInput: true };
}
}
// Vérifier les patterns qui attendent une entrée
if (lastLine.endsWith(':') || lastLine.endsWith('?') ||
lastLine.includes('[y/n]') || lastLine.includes('(yes/no)')) {
return { pattern: 'generic', response: null, needsInput: true };
}
return { needsInput: false };
}
// Exécution de commande avec pool de connexions
async function executeCommand(jobId) {
const job = queue.getJob(jobId);
if (!job) return queue.log('error', `Tâche introuvable: ${jobId}`);
let connection = null;
try {
const serverConfig = await serverManager.getServer(job.alias);
queue.updateJobStatus(jobId, 'running');
// Utiliser le pool de connexions si pas de mode interactif
const usePool = !job.interactive && job.persistent !== false;
if (usePool) {
// Obtenir une connexion du pool
connection = await sshPool.getConnection(job.alias, serverConfig);
await executeWithPooledConnection(connection, job, jobId);
} else {
// Créer une connexion dédiée pour les commandes interactives
await executeWithNewConnection(serverConfig, job, jobId);
}
} catch (err) {
queue.updateJobStatus(jobId, 'failed', { error: err.message });
} finally {
// Libérer la connexion si elle vient du pool
if (connection) {
sshPool.releaseConnection(connection.id);
}
}
}
// Exécution avec une connexion du pool
async function executeWithPooledConnection(connection, job, jobId) {
return new Promise((resolve, reject) => {
const { client } = connection;
const isStreaming = job.streaming || isStreamingCommand(job.cmd);
let cmdToExecute = job.cmd;
if (isStreaming) {
const maxLines = job.maxLines || 100;
const streamTimeout = job.streamTimeout || 10;
if (/^pm2\s+logs?\b/i.test(cmdToExecute)) {
cmdToExecute = cmdToExecute.replace(/\s+--lines\s+\d+/gi, '').replace(/\s+--nostream/gi, '');
cmdToExecute += ` --lines ${maxLines} --nostream`;
} else if (/^docker\s+logs?\b/i.test(cmdToExecute)) {
cmdToExecute = cmdToExecute.replace(/\s+-f\b|--follow\b/gi, '').replace(/\s+--tail\s+\d+/gi, '');
cmdToExecute += ` --tail ${maxLines}`;
} else if (/^tail\s+-f/i.test(cmdToExecute)) {
cmdToExecute = `timeout ${streamTimeout} ` + cmdToExecute.replace(/-f\b/gi, '') + ` | head -n ${maxLines}`;
} else if (/^journalctl\s+-f/i.test(cmdToExecute)) {
cmdToExecute = cmdToExecute.replace(/-f\b/gi, '') + ` -n ${maxLines}`;
} else {
cmdToExecute = `timeout ${streamTimeout} ${cmdToExecute}`;
}
queue.log('info', `Commande streaming transformée: ${cmdToExecute}`);
}
client.exec(cmdToExecute, { pty: job.pty }, (err, stream) => {
if (err) {
reject(err);
return;
}
let output = '';
let stderr = '';
let lineCount = 0;
const startTime = Date.now();
const maxLines = job.maxLines || 1000;
const timeout = job.timeout || config.defaultCommandTimeout;
const timeoutId = setTimeout(() => {
stream.write('\x03'); // Envoyer Ctrl+C
setTimeout(() => {
stream.close();
const duration = Date.now() - startTime;
const result = { output: output.trim(), stderr: stderr.trim(), exitCode: 124, duration, lineCount, timedOut: true };
queue.updateJobStatus(jobId, 'completed', result);
resolve(result);
}, 500);
}, timeout);
stream.on('close', (code, signal) => {
clearTimeout(timeoutId);
const duration = Date.now() - startTime;
const result = { output: output.trim(), stderr: stderr.trim(), exitCode: code, signal, duration, lineCount };
if (code === 0 || code === 124) {
queue.updateJobStatus(jobId, 'completed', result);
resolve(result);
} else {
queue.updateJobStatus(jobId, 'failed', { ...result, error: `Commande terminée avec code ${code}` });
reject(new Error(`Exit code: ${code}`));
}
});
stream.on('data', (data) => {
const chunk = data.toString();
output += chunk;
lineCount += (chunk.match(/\n/g) || []).length;
if (isStreaming && lineCount >= maxLines) {
stream.write('\x03');
setTimeout(() => stream.close(), 500);
}
if (job.autoRespond) {
const prompt = detectInteractivePrompt(output);
if (prompt.needsInput && prompt.response) {
stream.write(prompt.response + '\n');
queue.log('info', `Réponse automatique au prompt: ${prompt.pattern}`);
}
}
});
stream.stderr.on('data', (data) => {
stderr += data.toString();
});
});
});
}
// Exécution avec une nouvelle connexion (pour commandes interactives)
async function executeWithNewConnection(serverConfig, job, jobId) {
return new Promise((resolve, reject) => {
const conn = new Client();
conn.on('ready', () => {
conn.shell((err, stream) => {
if (err) {
conn.end();
return reject(err);
}
let output = '';
const responses = job.responses || {};
const END_MARKER = `GEMINI_TASK_COMPLETE_MARKER_${job.id}`;
let responseSent = false;
const cleanupAndResolve = (status = 'completed') => {
clearTimeout(jobTimeout);
output = output.replace(END_MARKER, '').trim();
const result = { output, exitCode: 0 };
queue.updateJobStatus(jobId, status, result);
try {
stream.end();
conn.end();
} catch(e) {/* ignore */}
resolve(result);
};
const jobTimeout = setTimeout(() => {
cleanupAndResolve('failed');
}, (job.timeout ? job.timeout * 1000 : config.interactiveCommandTimeout));
stream.on('close', () => {
if (!output.includes(END_MARKER)) {
cleanupAndResolve('failed');
}
});
stream.on('data', (data) => {
const chunk = data.toString();
output += chunk;
if (output.includes(END_MARKER)) {
cleanupAndResolve();
return;
}
if (!responseSent && job.autoRespond) {
const lowerChunk = chunk.toLowerCase();
let responseToSend = null;
// Chercher une réponse personnalisée
for (const [pattern, response] of Object.entries(responses)) {
if (lowerChunk.includes(pattern.toLowerCase())) {
responseToSend = response;
queue.log('info', `Réponse interactive: ${pattern} -> ${response}`);
break;
}
}
// Sinon, chercher une réponse par défaut
if (!responseToSend) {
for (const [pattern, response] of Object.entries(INTERACTIVE_RESPONSES)) {
if (response && lowerChunk.includes(pattern.toLowerCase())) {
responseToSend = response;
queue.log('info', `Réponse auto: ${pattern} -> ${response}`);
break;
}
}
}
// Si une réponse a été trouvée, l'envoyer
if (responseToSend) {
stream.write(responseToSend + '\n');
responseSent = true;
// Envoyer le marqueur de fin juste après
setTimeout(() => stream.write(`echo "${END_MARKER}"; exit\n`), 100);
}
}
});
stream.stderr.on('data', (data) => {
output += `[STDERR] ${data.toString()}`;
});
// Envoyer uniquement la commande initiale
stream.write(job.cmd + '\n');
});
});
conn.on('error', (err) => {
reject(err);
});
const connectConfig = {
host: serverConfig.host,
port: 22,
username: serverConfig.user,
readyTimeout: 20000
};
(async () => {
if (serverConfig.keyPath) {
try {
connectConfig.privateKey = await fs.readFile(serverConfig.keyPath);
} catch (err) {
reject(new Error(`Impossible de lire la clé SSH: ${err.message}`));
return;
}
} else if (serverConfig.password) {
connectConfig.password = serverConfig.password;
} else {
reject(new Error("Aucune méthode d'authentification configurée"));
return;
}
conn.connect(connectConfig);
})();
});
}
// Nouvelle fonction pour exécuter plusieurs commandes en séquence
async function executeCommandSequence(jobId) {
const job = queue.getJob(jobId);
if (!job) return queue.log('error', `Tâche introuvable: ${jobId}`);
const results = [];
let connection = null;
try {
const serverConfig = await serverManager.getServer(job.alias);
queue.updateJobStatus(jobId, 'running');
// Obtenir une connexion du pool
connection = await sshPool.getConnection(job.alias, serverConfig);
// Exécuter chaque commande en séquence
for (let i = 0; i < job.commands.length; i++) {
const cmd = job.commands[i];
const stepJob = {
...job,
cmd: typeof cmd === 'string' ? cmd : cmd.command,
timeout: cmd.timeout || job.timeout,
continueOnError: cmd.continueOnError || job.continueOnError
};
try {
queue.log('info', `Exécution étape ${i+1}/${job.commands.length}: ${stepJob.cmd}`);
const result = await executeWithPooledConnection(connection, stepJob, `${jobId}_step_${i}`);
results.push({
step: i + 1,
command: stepJob.cmd,
success: true,
...result
});
} catch (err) {
results.push({
step: i + 1,
command: stepJob.cmd,
success: false,
error: err.message
});
if (!stepJob.continueOnError) {
throw new Error(`Échec à l'étape ${i+1}: ${err.message}`);
}
}
}
queue.updateJobStatus(jobId, 'completed', { results });
} catch (err) {
queue.updateJobStatus(jobId, 'failed', {
error: err.message,
results
});
} finally {
if (connection) {
sshPool.releaseConnection(connection.id);
}
}
}
// Obtenir les stats du pool
function getPoolStats() {
return sshPool.getStats();
}
function parseSystemResources(output) {
const resources = {
load_average: null,
memory: null,
disk: null
};
const lines = output.split('\n');
try {
const uptimeLine = lines.find(line => line.includes('load average:'));
if (uptimeLine) {
const parts = uptimeLine.split('load average:')[1];
resources.load_average = parts.split(',').map(s => s.trim());
}
const memLine = lines.find(line => line.trim().startsWith('Mem:'));
if (memLine) {
const parts = memLine.trim().split(/\s+/);
resources.memory = {
total: parts[1],
used: parts[2],
free: parts[3],
available: parts[6]
};
}
const diskLine = lines.find(line => line.startsWith('/dev/'));
if (diskLine) {
const parts = diskLine.trim().split(/\s+/);
resources.disk = {
filesystem: parts[0],
total: parts[1],
used: parts[2],
available: parts[3],
use_percent: parts[4]
};
}
} catch (e) {
// En cas d'erreur de parsing, retourner les données brutes
return { raw_output: output, parsing_error: e.message };
}
return resources;
}
function parseServicesStatus(output) {
const services = {
systemd: [],
docker: [],
pm2: []
};
try {
const sections = output.split(/---DOCKER---|---PM2---/);
const systemdRaw = sections[0] || '';
const dockerRaw = sections[1] || '';
const pm2Raw = sections[2] || '';
// Parse systemd
if (systemdRaw) {
const lines = systemdRaw.trim().split('\n');
lines.forEach(line => {
const match = line.match(/^(\S+)\s+loaded\s+active\s+running/);
if (match && match[1]) {
services.systemd.push({ name: match[1], status: 'running' });
}
});
}
// Parse Docker
if (dockerRaw) {
const lines = dockerRaw.trim().split('\n').filter(Boolean);
lines.forEach(line => {
const parts = line.split(': ');
if (parts.length >= 2) {
services.docker.push({ name: parts[0].trim(), status: parts.slice(1).join(': ').trim() });
}
});
}
// Parse PM2
if (pm2Raw) {
const lines = pm2Raw.trim().split('\n');
const tableStartIndex = lines.findIndex(line => line.includes('│ id │'));
if (tableStartIndex !== -1) {
const tableBody = lines.slice(tableStartIndex + 2, -1); // Skip header, separator, and footer
tableBody.forEach(line => {
const columns = line.split('│').map(s => s.trim()).filter(Boolean);
if (columns.length >= 4) { // id, name, namespace, status
services.pm2.push({ id: columns[0], name: columns[1], status: columns[3] });
}
});
}
}
} catch (e) {
return { raw_output: output, parsing_error: e.message };
}
return services;
}
function parseApiHealth(output) {
if (!output || typeof output !== 'string') {
return {
status: 'ERROR',
http_code: 0,
response_time_ms: 0,
error: 'Sortie invalide ou vide'
};
}
try {
const parts = output.trim().split(':');
if (parts.length !== 2) {
return {
status: 'ERROR',
http_code: 0,
response_time_ms: 0,
error: 'Format de réponse invalide',
raw_output: output
};
}
const [codeStr, timeStr] = parts;
const http_code = parseInt(codeStr, 10);
const response_time_ms = parseFloat(timeStr) * 1000;
if (isNaN(http_code) || isNaN(response_time_ms)) {
return {
status: 'ERROR',
http_code: 0,
response_time_ms: 0,
error: 'Valeurs non numériques',
raw_output: output
};
}
return {
status: http_code >= 200 && http_code < 300 ? 'UP' : 'DOWN',
http_code: http_code,
response_time_ms: Math.round(response_time_ms)
};
} catch (e) {
return {
status: 'ERROR',
http_code: 0,
response_time_ms: 0,
error: e.message,
raw_output: output
};
}
}
export default {
executeCommand,
executeCommandSequence,
getPoolStats,
parseSystemResources,
parseServicesStatus,
parseApiHealth
};