const Docker = require('dockerode'); const { exec } = require('child_process'); const path = require('path'); const config = require('./config'); const docker = new Docker({ socketPath: '/var/run/docker.sock' }); /** * Récupérer les informations d'un conteneur Docker */ async function getContainerInfo(containerName) { try { const container = docker.getContainer(containerName); const info = await container.inspect(); return { id: info.Id.substring(0, 12), name: info.Name.replace('/', ''), state: info.State.Status, running: info.State.Running, startedAt: info.State.StartedAt, image: info.Config.Image, created: info.Created, }; } catch (err) { return null; } } /** * Récupérer les logs d'un conteneur */ async function getContainerLogs(containerName, tail = 100) { try { const container = docker.getContainer(containerName); const logs = await container.logs({ stdout: true, stderr: true, tail: tail, timestamps: true, }); // Parse buffer to string return logs.toString('utf8'); } catch (err) { return `Erreur lors de la récupération des logs: ${err.message}`; } } /** * Lister tous les conteneurs */ async function listContainers() { try { const containers = await docker.listContainers({ all: true }); return containers.map((c) => ({ id: c.Id.substring(0, 12), names: c.Names.map((n) => n.replace('/', '')), state: c.State, status: c.Status, image: c.Image, created: new Date(c.Created * 1000).toISOString(), })); } catch (err) { return []; } } /** * Redéployer une application (docker compose build + up) */ function redeployApp(appConfig) { return new Promise((resolve, reject) => { const appDir = path.join(config.appsBasePath, appConfig.directory); const cmd = `cd ${appDir} && docker compose build --no-cache && docker compose up -d`; const startTime = Date.now(); const logLines = []; logLines.push(`[${new Date().toISOString()}] Début du redéploiement de ${appConfig.name}`); logLines.push(`[${new Date().toISOString()}] Répertoire: ${appDir}`); logLines.push(`[${new Date().toISOString()}] Commande: ${cmd}`); const process = exec(cmd, { maxBuffer: 1024 * 1024 * 10, // 10MB timeout: 300000, // 5 minutes }); process.stdout.on('data', (data) => { const lines = data.toString().split('\n').filter(Boolean); lines.forEach((line) => { logLines.push(`[${new Date().toISOString()}] ${line}`); }); }); process.stderr.on('data', (data) => { const lines = data.toString().split('\n').filter(Boolean); lines.forEach((line) => { logLines.push(`[${new Date().toISOString()}] ${line}`); }); }); process.on('close', (code) => { const duration = ((Date.now() - startTime) / 1000).toFixed(1); if (code === 0) { logLines.push(`[${new Date().toISOString()}] Redéploiement terminé avec succès en ${duration}s`); resolve({ success: true, logs: logLines.join('\n'), duration }); } else { logLines.push(`[${new Date().toISOString()}] Redéploiement échoué (code: ${code}) en ${duration}s`); resolve({ success: false, logs: logLines.join('\n'), duration, exitCode: code }); } }); process.on('error', (err) => { logLines.push(`[${new Date().toISOString()}] Erreur: ${err.message}`); resolve({ success: false, logs: logLines.join('\n'), error: err.message }); }); }); } /** * Pull le dernier code depuis Gitea */ function gitPull(appConfig) { return new Promise((resolve, reject) => { const srcDir = path.join(config.appsBasePath, appConfig.directory, 'src'); const cmd = `cd ${srcDir} && git pull origin main 2>&1 || git pull origin master 2>&1`; exec(cmd, { timeout: 60000 }, (error, stdout, stderr) => { if (error) { resolve({ success: false, output: stderr || error.message }); } else { resolve({ success: true, output: stdout }); } }); }); } /** * Démarrer un conteneur Docker */ async function startContainer(containerName, appId) { try { // Tenter d'abord un docker start (conteneur existant mais arrêté) const container = docker.getContainer(containerName); const info = await container.inspect().catch(() => null); if (info) { // Le conteneur existe, on le démarre if (!info.State.Running) { await container.start(); } return { success: true, message: `Conteneur ${containerName} démarré` }; } // Le conteneur n'existe pas : docker compose up dans le répertoire de l'app const id = appId || containerName; const appsBasePath = config.appsBasePath || process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps'; const appDir = path.join(appsBasePath, id); const fs = require('fs'); const composeFile = fs.existsSync(path.join(appDir, 'docker-compose.yml')) ? path.join(appDir, 'docker-compose.yml') : fs.existsSync(path.join(appDir, 'docker-compose.prod.yml')) ? path.join(appDir, 'docker-compose.prod.yml') : null; if (!composeFile) { return { success: false, message: `Aucun docker-compose.yml trouvé dans ${appDir}` }; } await new Promise((resolve, reject) => { exec(`docker compose -f ${composeFile} up -d`, { cwd: appDir }, (err, stdout, stderr) => { if (err) reject(new Error(stderr || err.message)); else resolve(stdout); }); }); return { success: true, message: `Conteneur ${containerName} créé et démarré via docker compose` }; } catch (err) { return { success: false, message: err.message }; } } /** * Arrêter un conteneur Docker */ async function stopContainer(containerName) { try { const container = docker.getContainer(containerName); await container.stop({ t: 10 }); return { success: true, message: `Conteneur ${containerName} arrêté` }; } catch (err) { return { success: false, message: err.message }; } } /** * Redémarrer un conteneur Docker */ async function restartContainer(containerName) { try { const container = docker.getContainer(containerName); await container.restart({ t: 10 }); return { success: true, message: `Conteneur ${containerName} redémarré` }; } catch (err) { return { success: false, message: err.message }; } } /** * Récupérer les métriques système du serveur (CPU, RAM, disque, réseau) */ function getServerMetrics() { return new Promise((resolve) => { const { execSync } = require("child_process"); const fs = require("fs"); try { // ===== Méthode 1 : Lire host-metrics.json généré par le script système de l'hôte ===== // Ce fichier est mis à jour toutes les 30s par /opt/manus-deploy/host-metrics.sh // Il contient les vraies métriques de l'hôte LXC (pas du nœud physique) const hostMetricsPath = '/opt/manus-deploy/host-metrics.json'; if (fs.existsSync(hostMetricsPath)) { try { const raw = fs.readFileSync(hostMetricsPath, 'utf8'); const hostMetrics = JSON.parse(raw); // Vérifier que le fichier est récent (moins de 2 minutes) const fileAge = Date.now() - new Date(hostMetrics.timestamp).getTime(); if (fileAge < 120000 && hostMetrics.memory && hostMetrics.memory.total > 0) { return resolve({ ...hostMetrics, timestamp: new Date().toISOString() }); } } catch(e) {} } // ===== Méthode 2 : Lecture directe de /proc (fallback) ===== // CPU : mesure via cgroup v2 usage_usec (méthode précise pour conteneur LXC) // Fallback sur /proc/stat normalisé par nproc si cgroup v2 non disponible let cpuUsage = 0; try { const ncpu = parseInt(execSync('nproc').toString().trim()) || 1; const cgroupPath = '/sys/fs/cgroup/cpu.stat'; const readUsageUsec = () => { const stat = fs.readFileSync(cgroupPath, 'utf8'); const m = stat.match(/^usage_usec\s+(\d+)/m); return m ? parseInt(m[1]) : null; }; const u1 = readUsageUsec(); if (u1 !== null) { // Méthode cgroup v2 : mesure la consommation CPU réelle du conteneur execSync('sleep 0.5'); const u2 = readUsageUsec(); // delta en microsecondes / (500ms * ncpu) = % CPU du conteneur const deltaUs = u2 - u1; cpuUsage = Math.min(100, Math.round(deltaUs / (500000 * ncpu) * 100)); } else { // Fallback : /proc/stat normalisé par nproc const parseCpuLine = (line) => line.trim().split(/\s+/).slice(1).map(Number); const s1 = parseCpuLine(fs.readFileSync('/proc/stat', 'utf8').split('\n').find(l => l.startsWith('cpu '))); execSync('sleep 0.3'); const s2 = parseCpuLine(fs.readFileSync('/proc/stat', 'utf8').split('\n').find(l => l.startsWith('cpu '))); const idle1 = s1[3], total1 = s1.reduce((a,b)=>a+b,0); const idle2 = s2[3], total2 = s2.reduce((a,b)=>a+b,0); const dIdle = idle2 - idle1, dTotal = total2 - total1; const rawCpu = dTotal > 0 ? (1 - dIdle / dTotal) * 100 : 0; // Normaliser par nproc pour éviter l'effet nœud physique sur LXC cpuUsage = Math.round(rawCpu / ncpu); } } catch(e) {} // RAM : lecture robuste multi-source // Priorité 1 : /host/proc/meminfo (hôte LXC monté via volume docker-compose) // Priorité 2 : /proc/meminfo si MemTotal <= 64 Go (lxcfs ou VPS direct) // Priorité 3 : Docker API MemTotal + ratio /proc/meminfo du nœud physique let memTotal = 0; let memFree = 0, memAvailable = 0, memUsed = 0, memBuffers = 0, memCached = 0; // Fonction de lecture de meminfo depuis un chemin donné const readMeminfo = (procPath) => { const raw = fs.readFileSync(procPath + '/meminfo', 'utf8'); const map = {}; raw.split('\n').forEach(line => { const idx = line.indexOf(':'); if (idx > 0) { const key = line.substring(0, idx).trim(); const rest = line.substring(idx + 1).trim(); const val = parseInt(rest.split(' ')[0]); if (Number.isFinite(val)) map[key] = val * 1024; } }); return map; }; try { let memMap = null; let sourceLabel = ''; // Priorité 1 : /host/proc monté depuis l'hôte LXC if (fs.existsSync('/host/proc/meminfo')) { try { const hostMap = readMeminfo('/host/proc'); if (hostMap['MemTotal'] > 0 && hostMap['MemTotal'] <= 64 * 1024 * 1024 * 1024) { memMap = hostMap; sourceLabel = 'host/proc'; } } catch(e) {} } // Priorité 2 : /proc direct si MemTotal <= 64 Go if (!memMap) { try { const procMap = readMeminfo('/proc'); if (procMap['MemTotal'] > 0 && procMap['MemTotal'] <= 64 * 1024 * 1024 * 1024) { memMap = procMap; sourceLabel = '/proc direct'; } } catch(e) {} } // Priorité 3 : Docker API MemTotal + ratio du nœud physique if (!memMap) { try { const dockerInfoRaw = execSync( 'curl -s --unix-socket /var/run/docker.sock http://localhost/info', { timeout: 3000 } ).toString(); const dockerInfo = JSON.parse(dockerInfoRaw); const dockerMemTotal = dockerInfo.MemTotal || 0; if (dockerMemTotal > 0) { // Lire le ratio d'utilisation depuis /proc du nœud physique const nodeMap = readMeminfo('/proc'); const nodeTotal = nodeMap['MemTotal'] || 1; const nodeAvail = nodeMap['MemAvailable'] || 0; const usageRatio = (nodeTotal - nodeAvail) / nodeTotal; // Appliquer ce ratio à la vraie RAM du VPS memTotal = dockerMemTotal; memUsed = Math.round(memTotal * usageRatio); memFree = memTotal - memUsed; memAvailable = memFree; sourceLabel = 'docker-api+ratio'; } } catch(e) {} } // Calculer les valeurs finales si memMap disponible if (memMap) { memTotal = memMap['MemTotal'] || 0; memFree = memMap['MemFree'] || 0; memAvailable = memMap['MemAvailable'] || 0; memBuffers = memMap['Buffers'] || 0; const sReclaimable = memMap['SReclaimable'] || 0; const shmem = memMap['Shmem'] || 0; memCached = (memMap['Cached'] || 0) + (sReclaimable > shmem ? sReclaimable - shmem : 0); // Utiliser MemAvailable comme référence car elle inclut les caches libérables // Identique à ce que `free` affiche dans la colonne 'disponible' memUsed = memTotal - memAvailable; if (memUsed < 0) memUsed = 0; } } catch(e) { // Fallback ultime try { const m = readMeminfo('/proc'); memTotal = m['MemTotal'] || 0; memFree = m['MemFree'] || 0; memAvailable = m['MemAvailable'] || 0; memUsed = memTotal - memAvailable; } catch(e2) {} } // Disque : df sur /opt/manus-deploy (point de montage de l'hôte LXC) // Utiliser /opt/manus-deploy car c'est un volume monté depuis l'hôte let diskTotal = 0, diskUsed = 0, diskFree = 0; try { const diskPath = fs.existsSync('/opt/manus-deploy') ? '/opt/manus-deploy' : '/'; const diskRaw = execSync('df -B1 ' + diskPath + ' | tail -1').toString().trim().split(/\s+/); diskTotal = parseInt(diskRaw[1]); diskUsed = parseInt(diskRaw[2]); diskFree = parseInt(diskRaw[3]); } catch(e) {} // Uptime et load : utiliser /host/proc si disponible (hôte LXC réel) const procBase = fs.existsSync('/host/proc/uptime') ? '/host/proc' : '/proc'; const uptimeRaw = fs.readFileSync(procBase + '/uptime', 'utf8').trim().split(' '); const uptimeSeconds = parseFloat(uptimeRaw[0]); const loadRaw = fs.readFileSync(procBase + '/loadavg', 'utf8').trim().split(' '); const load1 = parseFloat(loadRaw[0]); const load5 = parseFloat(loadRaw[1]); const load15 = parseFloat(loadRaw[2]); // Réseau let netRx = 0, netTx = 0; try { const netDevPath = fs.existsSync('/host/proc/net/dev') ? '/host/proc/net/dev' : '/proc/net/dev'; const netDev = fs.readFileSync(netDevPath, 'utf8'); const netLine = netDev.split('\n').find(l => /eth0|ens|enp/.test(l)); if (netLine) { const parts = netLine.trim().split(/\s+/); netRx = parseInt(parts[1]) || 0; netTx = parseInt(parts[9]) || 0; } } catch(e) {} resolve({ cpu: { usage: cpuUsage }, memory: { total: memTotal, used: memUsed, free: memFree, available: memAvailable, buffers: memBuffers, cached: memCached, // usagePercent = RAM applicative réelle (hors cache disque, identique à `free`) usagePercent: Math.round((memUsed / memTotal) * 100) }, disk: { total: diskTotal, used: diskUsed, free: diskFree, usagePercent: Math.round((diskUsed / diskTotal) * 100) }, uptime: uptimeSeconds, load: { load1, load5, load15 }, network: { rx: netRx, tx: netTx }, timestamp: new Date().toISOString() }); } catch (err) { resolve({ error: err.message }); } }); } module.exports = { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics, };