144 lines
6.2 KiB
JavaScript
144 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',
|
|
'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;
|