Files
manus-dashboard/src/backend/src/webhook.js

145 lines
6.2 KiB
JavaScript

/**
* Manus Dashboard - Webhook Handler (CI/CD Gitea)
* Route : POST /api/webhook/gitea
* Écoute les événements push de Gitea et déclenche le déploiement automatique.
*/
const express = require('express');
const crypto = require('crypto');
const { exec } = require('child_process');
const path = require('path');
const fs = require('fs');
const router = express.Router();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '';
const APPS_BASE_PATH = process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps';
const DEPLOY_SCRIPT = '/opt/manus-deploy/scripts/deploy-app.sh';
const LOG_DIR = '/var/log/manus-deploy';
// Mapping dépôt Gitea -> nom du dossier application sur le serveur
const REPO_TO_APP_MAP = {
'itinova-contacts': 'itinova-contacts',
'gestion-at-v2': 'gestion-at-v2',
'itinova-podcasts': 'itinova-podcasts',
'veille-reglementaire': 'veille-reglementaire',
'itinova-vehicle-exchange': 'itinova-vehicle-exchange',
'manus-dashboard': 'manus-dashboard',
'itinova-budget-si': 'itinova-budget-si',
};
// Déploiements en cours (évite les doubles déclenchements)
const deployingApps = new Set();
function verifyGiteaSignature(req) {
if (!WEBHOOK_SECRET) {
console.warn('[Webhook] AVERTISSEMENT: WEBHOOK_SECRET non défini. Validation désactivée.');
return true;
}
const signature = req.headers['x-gitea-signature'] || req.headers['x-hub-signature-256'] || '';
console.log('[Webhook DEBUG] All headers:', JSON.stringify(Object.keys(req.headers)));
console.log('[Webhook DEBUG] x-gitea-signature:', req.headers['x-gitea-signature'] || 'ABSENT');
console.log('[Webhook DEBUG] rawBody available:', !!req.rawBody, 'length:', req.rawBody ? req.rawBody.length : 0);
// Utiliser rawBody si disponible (préservé avant express.json), sinon re-sérialiser
const bodyStr = req.rawBody ? req.rawBody.toString() : JSON.stringify(req.body);
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET).update(bodyStr).digest('hex');
const expected = `sha256=${hmac}`;
const received = signature.startsWith('sha256=') ? signature : `sha256=${signature}`;
try {
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
} catch {
return false;
}
}
function runDeploy(appName, branch, commitHash, committer, broadcast) {
if (deployingApps.has(appName)) {
console.log(`[Webhook] Déploiement déjà en cours pour ${appName}. Ignoré.`);
return;
}
deployingApps.add(appName);
console.log(`[Webhook] Déclenchement déploiement: ${appName} (branch: ${branch}, commit: ${commitHash})`);
if (broadcast) {
broadcast({ type: 'deploy_started', data: { app: appName, commit: commitHash, branch, committer } });
}
const env = { ...process.env, APPS_BASE_PATH };
const cmd = `bash ${DEPLOY_SCRIPT} ${appName} recette`;
let output = '';
const child = exec(cmd, { env, timeout: 600000 });
child.stdout.on('data', (d) => { output += d; process.stdout.write(`[${appName}] ${d}`); });
child.stderr.on('data', (d) => { output += d; process.stderr.write(`[${appName}] ERR: ${d}`); });
child.on('close', (code) => {
deployingApps.delete(appName);
const status = code === 0 ? 'success' : 'failed';
console.log(`[Webhook] Déploiement ${status} pour ${appName} (exit: ${code})`);
if (broadcast) {
broadcast({ type: 'deploy_finished', data: { app: appName, status, exitCode: code } });
}
try {
fs.mkdirSync(LOG_DIR, { recursive: true });
fs.writeFileSync(path.join(LOG_DIR, `${appName}-last-deploy.json`), JSON.stringify({
app: appName, branch, commit: commitHash, committer, status, exitCode: code,
timestamp: new Date().toISOString(), output: output.slice(-3000),
}, null, 2));
} catch (e) { console.error('[Webhook] Erreur sauvegarde statut:', e.message); }
});
}
// POST /api/webhook/gitea
router.post('/gitea', (req, res) => {
if (!verifyGiteaSignature(req)) {
console.warn('[Webhook] Signature invalide. Requête rejetée.');
return res.status(401).json({ error: 'Signature invalide' });
}
const event = req.headers['x-gitea-event'] || 'unknown';
if (event !== 'push') {
return res.status(200).json({ message: `Événement ${event} ignoré` });
}
const payload = req.body;
const repoName = payload?.repository?.name || '';
const branch = (payload?.ref || '').replace('refs/heads/', '');
const commitHash = (payload?.after || '').slice(0, 8) || 'unknown';
const committer = payload?.pusher?.login || payload?.pusher?.name || 'unknown';
console.log(`[Webhook] Push: repo=${repoName}, branch=${branch}, commit=${commitHash}, by=${committer}`);
const appName = REPO_TO_APP_MAP[repoName];
if (!appName) return res.status(200).json({ message: `Dépôt ${repoName} non configuré` });
if (branch !== 'main') return res.status(200).json({ message: `Branch ${branch} ignorée` });
res.status(202).json({ message: `Déploiement de ${appName} déclenché`, commit: commitHash, branch, committer });
// Récupérer la fonction broadcast si disponible globalement
const broadcastFn = global.wsBroadcast || null;
setImmediate(() => runDeploy(appName, branch, commitHash, committer, broadcastFn));
});
// GET /api/webhook/status/:appName
router.get('/status/:appName', (req, res) => {
const { appName } = req.params;
const statusFile = path.join(LOG_DIR, `${appName}-last-deploy.json`);
if (!fs.existsSync(statusFile)) return res.status(404).json({ error: 'Aucun déploiement enregistré' });
try {
const status = JSON.parse(fs.readFileSync(statusFile, 'utf8'));
res.json({ ...status, isDeploying: deployingApps.has(appName) });
} catch { res.status(500).json({ error: 'Erreur lecture statut' }); }
});
// GET /api/webhook/status
router.get('/status', (req, res) => {
try {
fs.mkdirSync(LOG_DIR, { recursive: true });
const files = fs.readdirSync(LOG_DIR).filter(f => f.endsWith('-last-deploy.json'));
const statuses = files.map(f => {
try { const d = JSON.parse(fs.readFileSync(path.join(LOG_DIR, f), 'utf8')); return { ...d, isDeploying: deployingApps.has(d.app) }; }
catch { return null; }
}).filter(Boolean);
res.json({ deployments: statuses, currentlyDeploying: [...deployingApps] });
} catch { res.status(500).json({ error: 'Erreur lecture statuts' }); }
});
module.exports = router;