236 lines
9.9 KiB
JavaScript
236 lines
9.9 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 { execFile } = require('child_process');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const config = require('./config');
|
||
const { findAppByRepo } = require('./app-registry');
|
||
const { getWorkflowRunForCommit } = require('./gitea');
|
||
const { normalizeWorkflowFailure, notifyDsi, saveCiAlert } = require('./ci-alerts');
|
||
|
||
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';
|
||
const DASHBOARD_APP_ID = 'manus-dashboard';
|
||
const DASHBOARD_DEPLOY_REQUEST = path.join(APPS_BASE_PATH, DASHBOARD_APP_ID, '.deployment-request');
|
||
const CI_GATE_TIMEOUT_MS = Number.parseInt(process.env.CI_GATE_TIMEOUT_MS, 10) || 5 * 60 * 1000;
|
||
const CI_GATE_POLL_INTERVAL_MS = Number.parseInt(process.env.CI_GATE_POLL_INTERVAL_MS, 10) || 3000;
|
||
|
||
// Déploiements en cours (évite les doubles déclenchements)
|
||
const deployingApps = new Set();
|
||
|
||
/**
|
||
* Un conteneur ne peut pas se recréer lui-même : il interromprait son propre
|
||
* client Docker. Le service systemd hôte surveille la demande atomique créée ici.
|
||
*/
|
||
function isHostManagedDeployment(appName) {
|
||
return appName === DASHBOARD_APP_ID;
|
||
}
|
||
|
||
function requestDashboardHostDeployment({ branch, commitHash, committer }) {
|
||
const temporaryRequest = `${DASHBOARD_DEPLOY_REQUEST}.${process.pid}.tmp`;
|
||
fs.writeFileSync(temporaryRequest, JSON.stringify({ branch, commitHash, committer, requestedAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
|
||
fs.renameSync(temporaryRequest, DASHBOARD_DEPLOY_REQUEST);
|
||
}
|
||
|
||
function shouldRequireCi(app) {
|
||
return app?.ci?.required === true;
|
||
}
|
||
|
||
function saveDeploymentStatus(appName, status) {
|
||
try {
|
||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||
fs.writeFileSync(path.join(LOG_DIR, `${appName}-last-deploy.json`), JSON.stringify(status, null, 2));
|
||
} catch (error) {
|
||
console.error('[Webhook] Erreur sauvegarde statut:', error.message);
|
||
}
|
||
}
|
||
|
||
function wait(delay) {
|
||
return new Promise((resolve) => setTimeout(resolve, delay));
|
||
}
|
||
|
||
async function waitForSuccessfulCi(app, commitHash) {
|
||
const owner = app.giteaOwner || 'manus-admin';
|
||
const repo = app.giteaRepo || app.id;
|
||
const deadline = Date.now() + CI_GATE_TIMEOUT_MS;
|
||
let lastRun = null;
|
||
while (Date.now() < deadline) {
|
||
lastRun = await getWorkflowRunForCommit(owner, repo, commitHash);
|
||
if (lastRun?.status === 'completed') {
|
||
return {
|
||
allowed: lastRun.conclusion === 'success',
|
||
run: lastRun,
|
||
reason: lastRun.conclusion === 'success' ? null : `La CI est terminée avec le statut ${lastRun.conclusion || 'inconnu'}`,
|
||
};
|
||
}
|
||
await wait(CI_GATE_POLL_INTERVAL_MS);
|
||
}
|
||
return {
|
||
allowed: false,
|
||
run: lastRun,
|
||
reason: lastRun ? 'La CI n’a pas terminé dans le délai autorisé' : 'Aucune validation CI trouvée pour ce commit',
|
||
};
|
||
}
|
||
|
||
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'] || '';
|
||
// 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 };
|
||
let output = '';
|
||
|
||
// Les deux serveurs partagent le module, mais leurs scripts de déploiement
|
||
// n’attendent pas le même second argument : recette attend son environnement,
|
||
// production attend la branche Git. Cette sélection évite tout mélange.
|
||
const deployTarget = config.environment === 'prod' ? 'main' : 'recette';
|
||
// execFile évite toute interprétation shell du nom de dossier issu du manifeste.
|
||
const child = execFile('bash', [DEPLOY_SCRIPT, appName, deployTarget], { 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 } });
|
||
}
|
||
saveDeploymentStatus(appName, {
|
||
app: appName, branch, commit: commitHash, committer, status, exitCode: code,
|
||
timestamp: new Date().toISOString(), output: output.slice(-3000),
|
||
});
|
||
});
|
||
}
|
||
|
||
async function gateAndDeploy(app, branch, commitHash, committer, broadcast) {
|
||
if (shouldRequireCi(app)) {
|
||
const ci = await waitForSuccessfulCi(app, commitHash);
|
||
if (!ci.allowed) {
|
||
const status = { app: app.directory, branch, commit: commitHash, committer, status: 'blocked', timestamp: new Date().toISOString(), ci: ci.run, reason: ci.reason };
|
||
saveDeploymentStatus(app.directory, status);
|
||
broadcast?.({ type: 'deploy_blocked', data: status });
|
||
return;
|
||
}
|
||
}
|
||
if (isHostManagedDeployment(app.directory)) {
|
||
requestDashboardHostDeployment({ branch, commitHash, committer });
|
||
return;
|
||
}
|
||
runDeploy(app.directory, branch, commitHash, committer, broadcast);
|
||
}
|
||
|
||
// 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 === 'workflow_run') {
|
||
const alert = normalizeWorkflowFailure(req.body, req.headers['x-gitea-delivery'] || null);
|
||
if (!alert) return res.status(200).json({ message: 'Workflow sans échec ignoré' });
|
||
saveCiAlert(alert);
|
||
global.wsBroadcast?.({ type: 'ci_alert', data: alert });
|
||
res.status(202).json({ message: 'Alerte CI enregistrée', id: alert.id });
|
||
setImmediate(async () => {
|
||
const sent = await notifyDsi(alert);
|
||
if (sent) {
|
||
const notifiedAlert = { ...alert, dsiNotificationSent: true };
|
||
saveCiAlert(notifiedAlert);
|
||
global.wsBroadcast?.({ type: 'ci_alert', data: notifiedAlert });
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
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}`);
|
||
|
||
// Le manifeste app.json associe le dépôt au dossier déployé : pas de mapping statique à maintenir.
|
||
const app = findAppByRepo(config, repoName);
|
||
if (!app) return res.status(200).json({ message: `Dépôt ${repoName} non déployé ou sans manifeste valide` });
|
||
if (branch !== 'main') return res.status(200).json({ message: `Branch ${branch} ignorée` });
|
||
|
||
res.status(202).json({
|
||
message: shouldRequireCi(app) ? `Validation CI requise avant déploiement de ${app.directory}` : `Déploiement de ${app.directory} déclenché`,
|
||
commit: commitHash,
|
||
branch,
|
||
ciRequired: shouldRequireCi(app),
|
||
});
|
||
|
||
// Récupérer la fonction broadcast si disponible globalement
|
||
const broadcastFn = global.wsBroadcast || null;
|
||
setImmediate(() => gateAndDeploy(app, 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;
|
||
module.exports.isHostManagedDeployment = isHostManagedDeployment;
|
||
module.exports.shouldRequireCi = shouldRequireCi;
|
||
module.exports.waitForSuccessfulCi = waitForSuccessfulCi;
|