/** * Ferrovia Panel - Service de Mise à Jour Automatique & Gestionnaire Système * * Serveur autonome Node.js (avec ou sans express) pour orchestrer les opérations * de maintenance : git fetch/pull, npm install, compilation build et redémarrage PM2. */ const http = require('http'); const { spawn, execSync } = require('child_process'); const path = require('path'); const fs = require('fs'); const PORT = parseInt(process.env.UPDATER_PORT || process.env.PORT || '4001', 10); const APP_DIR = path.resolve(__dirname); // État global du processus de mise à jour let updateState = { status: 'idle', // 'idle' | 'checking' | 'updating' | 'success' | 'error' progress: 0, step: '', error: null, startedAt: null, finishedAt: null, logs: [] }; /** * Ajout d'une ligne de journal horodatée avec typage */ function addLog(text, type = 'info') { const timestamp = new Date().toLocaleTimeString('fr-FR', { hour12: false }); const logItem = { time: timestamp, text: String(text).trim(), type: type // 'info' | 'stdout' | 'stderr' | 'success' | 'error' | 'cmd' }; console.log(`[${timestamp}] [${type.toUpperCase()}] ${logItem.text}`); updateState.logs.push(logItem); // Conserver les 300 derniers messages pour éviter la saturation mémoire if (updateState.logs.length > 300) { updateState.logs.shift(); } } /** * Exécute une commande shell de manière asynchrone avec streaming des logs */ function runCommand(command, args, stepName, progressStart, progressEnd, options = {}) { return new Promise((resolve, reject) => { updateState.step = stepName; updateState.progress = progressStart; addLog(`>>> Exécution: ${command} ${args.join(' ')}`, 'cmd'); const proc = spawn(command, args, { cwd: APP_DIR, shell: true, env: { ...process.env, ...(options.env || {}) } }); proc.stdout.on('data', (data) => { const lines = data.toString().split('\n').filter(Boolean); lines.forEach(line => addLog(line, 'stdout')); }); proc.stderr.on('data', (data) => { const lines = data.toString().split('\n').filter(Boolean); lines.forEach(line => { // Certains outils (npm, git) écrivent des infos normales sur stderr const isRealError = /error|fatal|fail/i.test(line); addLog(line, isRealError ? 'stderr' : 'info'); }); }); proc.on('error', (err) => { addLog(`Erreur de lancement de la commande: ${err.message}`, 'error'); reject(err); }); proc.on('close', (code) => { if (code !== 0) { const errMsg = `La commande "${command} ${args.join(' ')}" a échoué avec le code de sortie ${code}`; addLog(`>>> ${errMsg}`, 'error'); reject(new Error(errMsg)); } else { updateState.progress = progressEnd; addLog(`>>> Étape réussie : ${stepName}`, 'success'); resolve(); } }); }); } /** * Récupère les métadonnées de version et de dépôt local */ function getLocalSystemInfo() { let version = '0.1.0'; try { const pkgJson = JSON.parse(fs.readFileSync(path.join(APP_DIR, 'package.json'), 'utf8')); version = pkgJson.version || version; } catch (e) { // Ignorer si package.json non lisible } let branch = 'main'; let commitHash = ''; let commitFullHash = ''; let commitMessage = ''; let commitDate = ''; let commitAuthor = ''; let remoteUrl = ''; try { branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: APP_DIR, encoding: 'utf8' }).trim(); } catch (e) { branch = 'inconnu'; } try { commitHash = execSync('git rev-parse --short HEAD', { cwd: APP_DIR, encoding: 'utf8' }).trim(); commitFullHash = execSync('git rev-parse HEAD', { cwd: APP_DIR, encoding: 'utf8' }).trim(); commitMessage = execSync('git log -1 --pretty=%B', { cwd: APP_DIR, encoding: 'utf8' }).trim(); commitDate = execSync('git log -1 --format=%cd --date=format:"%d/%m/%Y %H:%M:%S"', { cwd: APP_DIR, encoding: 'utf8' }).trim(); commitAuthor = execSync('git log -1 --format=%an', { cwd: APP_DIR, encoding: 'utf8' }).trim(); } catch (e) { // Ignorer si non disponible } try { remoteUrl = execSync('git config --get remote.origin.url', { cwd: APP_DIR, encoding: 'utf8' }).trim(); } catch (e) { remoteUrl = ''; } return { version, branch, commitHash, commitFullHash, commitMessage, commitDate, commitAuthor, remoteUrl, nodeVersion: process.version, platform: process.platform, pid: process.pid, uptime: Math.round(process.uptime()) }; } /** * Vérifie l'état des mises à jour sur le dépôt Git distant */ async function checkGitUpdates() { try { // 1. Fetch silencieux execSync('git fetch origin', { cwd: APP_DIR, encoding: 'utf8', timeout: 15000 }); let branch = 'main'; try { branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: APP_DIR, encoding: 'utf8' }).trim(); } catch (e) {} // 2. Compter les commits de retard let commitsBehind = 0; try { const behindCountStr = execSync(`git rev-list --count HEAD..origin/${branch}`, { cwd: APP_DIR, encoding: 'utf8' }).trim(); commitsBehind = parseInt(behindCountStr, 10) || 0; } catch (e) { // Si pas de branche distante correspondante commitsBehind = 0; } // 3. Récupérer la liste des commits entrants let incomingCommits = []; if (commitsBehind > 0) { try { const logOutput = execSync(`git log HEAD..origin/${branch} --format="%h|%an|%ar|%ad|%s" --date=format:"%d/%m/%Y %H:%M" -n 25`, { cwd: APP_DIR, encoding: 'utf8' }).trim(); if (logOutput) { incomingCommits = logOutput.split('\n').map(line => { const [hash, author, relativeDate, date, ...msgParts] = line.split('|'); return { hash, author, relativeDate, date, message: msgParts.join('|') }; }); } } catch (e) { // Ignore parsing errors } } const localInfo = getLocalSystemInfo(); return { success: true, hasUpdate: commitsBehind > 0, commitsBehind, branch, incomingCommits, localInfo, checkedAt: new Date().toISOString() }; } catch (err) { return { success: false, hasUpdate: false, error: `Impossible de contacter le dépôt distant : ${err.message}`, localInfo: getLocalSystemInfo(), checkedAt: new Date().toISOString() }; } } /** * Pipeline d'exécution de la mise à jour */ async function executeUpdatePipeline(options = {}) { if (updateState.status === 'updating') { throw new Error('Une mise à jour est déjà en cours.'); } updateState = { status: 'updating', progress: 5, step: 'Initialisation de la mise à jour', error: null, startedAt: new Date().toISOString(), finishedAt: null, logs: [] }; addLog('🚀 Démarrage du pipeline de mise à jour Ferrovia Panel...', 'info'); addLog(`Répertoire applicatif : ${APP_DIR}`, 'info'); try { // Étape 1 : Récupération Git (5% -> 35%) let branch = 'main'; try { branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: APP_DIR, encoding: 'utf8' }).trim(); } catch (e) {} await runCommand('git', ['fetch', 'origin'], 'Synchronisation des métadonnées Git', 10, 20); // Si modifications locales non commitées, faire un stash temporaire sécurisé let hasStashed = false; try { const statusOutput = execSync('git status --porcelain', { cwd: APP_DIR, encoding: 'utf8' }).trim(); if (statusOutput && options.stash !== false) { addLog('Modifications locales détectées, mise en réserve (git stash)...', 'info'); execSync('git stash', { cwd: APP_DIR, encoding: 'utf8' }); hasStashed = true; } } catch (e) {} await runCommand('git', ['pull', 'origin', branch], `Téléchargement des dernières modifications (branche ${branch})`, 20, 40); // Étape 2 : Installation des dépendances NPM (40% -> 65%) if (options.skipNpmInstall !== true) { await runCommand('npm', ['install', '--no-audit', '--no-fund'], 'Installation & mise à jour des dépendances (npm install)', 40, 65); } else { addLog('Étape npm install ignorée selon les paramètres.', 'info'); updateState.progress = 65; } // Étape 3 : Compilation de l'application React (65% -> 90%) if (options.skipBuild !== true) { await runCommand('npm', ['run', 'build'], 'Compilation de l\'application (npm run build)', 65, 90); } else { addLog('Étape de build ignorée selon les paramètres.', 'info'); updateState.progress = 90; } // Étape 4 : Détection PM2 & Redémarrage (90% -> 100%) updateState.step = 'Finalisation du déploiement'; updateState.progress = 95; let pm2Reloaded = false; if (options.restartPm2 !== false) { try { // Tester si PM2 est actif execSync('pm2 --version', { stdio: 'ignore' }); addLog('PM2 détecté sur le système. Tentative de rechargement...', 'info'); try { execSync('pm2 reload all || pm2 restart all', { cwd: APP_DIR, encoding: 'utf8', stdio: 'ignore' }); addLog('Processus PM2 rechargés avec succès.', 'success'); pm2Reloaded = true; } catch (e) { addLog(`Avertissement PM2 : ${e.message}`, 'info'); } } catch (e) { addLog('PM2 non présent ou non configuré pour ce serveur.', 'info'); } } updateState.status = 'success'; updateState.step = 'Mise à jour terminée avec succès'; updateState.progress = 100; updateState.finishedAt = new Date().toISOString(); addLog('🎉 Félicitations ! Ferrovia Panel a été mis à jour avec succès.', 'success'); return { success: true, pm2Reloaded }; } catch (err) { updateState.status = 'error'; updateState.step = 'Échec de la mise à jour'; updateState.error = err.message; updateState.finishedAt = new Date().toISOString(); addLog(`❌ ERREUR CRITIQUE : ${err.message}`, 'error'); throw err; } } /** * Serveur HTTP natif Node.js avec gestion universelle des en-têtes CORS et JSON */ const server = http.createServer(async (req, res) => { // 1. En-têtes CORS complets res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); res.setHeader('Access-Control-Max-Age', '86400'); // Répondre aux requêtes preflight OPTIONS if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const pathname = parsedUrl.pathname; const sendJson = (statusCode, data) => { res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(data)); }; const parseBody = () => { return new Promise((resolve) => { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', () => { try { resolve(body ? JSON.parse(body) : {}); } catch (e) { resolve({}); } }); }); }; try { // --- ROUTE: Health check --- if (pathname === '/api/health' || pathname === '/api/update/health') { return sendJson(200, { status: 'ok', service: 'ferrovia-updater', version: '2.0.0', uptime: Math.round(process.uptime()), pid: process.pid, port: PORT, appDir: APP_DIR }); } // --- ROUTE: Local System Information --- if (pathname === '/api/update/info' && req.method === 'GET') { const info = getLocalSystemInfo(); return sendJson(200, info); } // --- ROUTE: Check Remote Updates --- if ((pathname === '/api/update/check' || pathname === '/api/check-update') && req.method === 'GET') { const checkResult = await checkGitUpdates(); return sendJson(200, checkResult); } // --- ROUTE: Start Update Pipeline --- if ((pathname === '/api/update/start' || pathname === '/api/update') && req.method === 'POST') { if (updateState.status === 'updating') { return sendJson(409, { error: 'Une mise à jour est déjà en cours d\'exécution.', state: updateState }); } const body = await parseBody(); // Démarrage asynchrone pour répondre immédiatement au client executeUpdatePipeline(body).catch(err => { console.error('[UPDATER_ERROR]', err); }); return sendJson(202, { message: 'Le processus de mise à jour a été initialisé.', status: updateState.status, startedAt: updateState.startedAt }); } // --- ROUTE: Real-time Update Status --- if (pathname === '/api/update/status' && req.method === 'GET') { return sendJson(200, updateState); } // --- ROUTE: Reset State --- if (pathname === '/api/update/reset' && req.method === 'POST') { if (updateState.status === 'updating') { return sendJson(400, { error: 'Impossible de réinitialiser pendant qu\'une mise à jour s\'exécute.' }); } updateState = { status: 'idle', progress: 0, step: '', error: null, startedAt: null, finishedAt: null, logs: [] }; return sendJson(200, { message: 'État réinitialisé avec succès.', state: updateState }); } // 404 Route non trouvée return sendJson(404, { error: `Route introuvable : ${req.method} ${pathname}` }); } catch (err) { console.error('[UPDATER_SERVER_ERROR]', err); return sendJson(500, { error: err.message || 'Erreur interne du serveur de mise à jour' }); } }); server.listen(PORT, '0.0.0.0', () => { console.log(`====================================================`); console.log(`🚆 FERROVIA PANEL - UPDATER DAEMON DÉMARRÉ`); console.log(`📡 Port d'écoute : http://localhost:${PORT}`); console.log(`📁 Répertoire : ${APP_DIR}`); console.log(`====================================================`); }); // Arrêt propre process.on('SIGTERM', () => { console.log('Fermeture du serveur updater (SIGTERM)...'); server.close(() => process.exit(0)); }); process.on('SIGINT', () => { console.log('Fermeture du serveur updater (SIGINT)...'); server.close(() => process.exit(0)); });