feat(ci): conditionner les déploiements et exposer les métriques
This commit is contained in:
25
.gitea/workflows/validate.yml
Normal file
25
.gitea/workflows/validate.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: Validation Dashboard
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- "**.md"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
name: Tests backend et build frontend
|
||||||
|
runs-on: ci-node22
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Installer et tester le backend
|
||||||
|
working-directory: src/backend
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm test
|
||||||
|
- name: Installer et construire le frontend
|
||||||
|
working-directory: src/frontend
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
5
app.json
5
app.json
@@ -9,5 +9,8 @@
|
|||||||
"containerName": "manus-dashboard",
|
"containerName": "manus-dashboard",
|
||||||
"image": "images/dashboard.png",
|
"image": "images/dashboard.png",
|
||||||
"giteaRepo": "manus-dashboard",
|
"giteaRepo": "manus-dashboard",
|
||||||
"giteaOwner": "manus-admin"
|
"giteaOwner": "manus-admin",
|
||||||
|
"ci": {
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"dev": "nodemon src/index.js",
|
"dev": "nodemon src/index.js",
|
||||||
"test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/webhook.test.js"
|
"test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/webhook.test.js && node test/gitea.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ function normalizeManifest(manifest, directory, environment) {
|
|||||||
if (!isHttpsUrl(manifest.urls[environment])) {
|
if (!isHttpsUrl(manifest.urls[environment])) {
|
||||||
throw new Error(`urls.${environment} absent ou invalide`);
|
throw new Error(`urls.${environment} absent ou invalide`);
|
||||||
}
|
}
|
||||||
|
if (manifest.ci !== undefined && (
|
||||||
|
!manifest.ci || typeof manifest.ci !== 'object' || Array.isArray(manifest.ci) ||
|
||||||
|
(manifest.ci.required !== undefined && typeof manifest.ci.required !== 'boolean')
|
||||||
|
)) {
|
||||||
|
throw new Error('ci doit être un objet avec une propriété required booléenne');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...manifest,
|
...manifest,
|
||||||
@@ -51,6 +57,7 @@ function normalizeManifest(manifest, directory, environment) {
|
|||||||
directory,
|
directory,
|
||||||
containerName: manifest.containerName || manifest.id,
|
containerName: manifest.containerName || manifest.id,
|
||||||
healthCheckUrl: manifest.urls[environment],
|
healthCheckUrl: manifest.urls[environment],
|
||||||
|
ci: { required: manifest.ci?.required === true },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,14 +138,19 @@ function writeAppManifest({ appsBasePath, environment, app }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findAppDirectoryByRepo(config, repositoryName) {
|
function findAppDirectoryByRepo(config, repositoryName) {
|
||||||
const { apps } = refreshApps(config);
|
const app = findAppByRepo(config, repositoryName);
|
||||||
const app = apps.find((candidate) => candidate.giteaRepo === repositoryName || candidate.id === repositoryName);
|
|
||||||
return app ? app.directory : null;
|
return app ? app.directory : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findAppByRepo(config, repositoryName) {
|
||||||
|
const { apps } = refreshApps(config);
|
||||||
|
return apps.find((candidate) => candidate.giteaRepo === repositoryName || candidate.id === repositoryName) || null;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
discoverApps,
|
discoverApps,
|
||||||
refreshApps,
|
refreshApps,
|
||||||
writeAppManifest,
|
writeAppManifest,
|
||||||
findAppDirectoryByRepo,
|
findAppDirectoryByRepo,
|
||||||
|
findAppByRepo,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -70,6 +70,45 @@ async function getBranches(owner, repo) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toDurationSeconds(startedAt, completedAt) {
|
||||||
|
if (!startedAt || !completedAt) return null;
|
||||||
|
const milliseconds = new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
||||||
|
return Number.isFinite(milliseconds) && milliseconds >= 0 ? Math.round(milliseconds / 1000) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWorkflowRun(run) {
|
||||||
|
const startedAt = run.run_started_at || run.started_at || null;
|
||||||
|
const completedAt = run.updated_at || (run.status === 'completed' ? run.created_at : null);
|
||||||
|
return {
|
||||||
|
id: run.id,
|
||||||
|
name: run.name || run.workflow_id || 'Validation CI',
|
||||||
|
status: run.status || 'unknown',
|
||||||
|
conclusion: run.conclusion || null,
|
||||||
|
event: run.event || null,
|
||||||
|
commit: run.head_sha || null,
|
||||||
|
createdAt: run.created_at || null,
|
||||||
|
startedAt,
|
||||||
|
completedAt,
|
||||||
|
durationSeconds: toDurationSeconds(startedAt, completedAt),
|
||||||
|
url: run.html_url || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWorkflowRuns(owner, repo, limit = 10) {
|
||||||
|
try {
|
||||||
|
const response = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs`, { params: { limit, page: 1 } });
|
||||||
|
return (response.data.workflow_runs || []).map(normalizeWorkflowRun);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Erreur Gitea getWorkflowRuns ${owner}/${repo}:`, err.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWorkflowRunForCommit(owner, repo, commitHash) {
|
||||||
|
const runs = await getWorkflowRuns(owner, repo, 30);
|
||||||
|
return runs.find((run) => run.commit && run.commit.startsWith(commitHash)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupérer tous les dépôts (avec retry en cas d'erreur DNS)
|
* Récupérer tous les dépôts (avec retry en cas d'erreur DNS)
|
||||||
*/
|
*/
|
||||||
@@ -113,6 +152,9 @@ module.exports = {
|
|||||||
getRepo,
|
getRepo,
|
||||||
getCommits,
|
getCommits,
|
||||||
getBranches,
|
getBranches,
|
||||||
|
getWorkflowRuns,
|
||||||
|
getWorkflowRunForCommit,
|
||||||
|
normalizeWorkflowRun,
|
||||||
listRepos,
|
listRepos,
|
||||||
createRepo,
|
createRepo,
|
||||||
giteaClient,
|
giteaClient,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { authenticate, authMiddleware } = require('./auth');
|
const { authenticate, authMiddleware } = require('./auth');
|
||||||
const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker');
|
const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker');
|
||||||
const { getRepo, getCommits, getBranches, listRepos } = require('./gitea');
|
const { getRepo, getCommits, getBranches, getWorkflowRuns, listRepos } = require('./gitea');
|
||||||
const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck');
|
const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck');
|
||||||
const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator');
|
const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator');
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
@@ -399,6 +399,15 @@ router.get('/gitea/repos/:owner/:repo/branches', authMiddleware, async (req, res
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/gitea/repos/:owner/:repo/actions/runs', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const runs = await getWorkflowRuns(req.params.owner, req.params.repo, 10);
|
||||||
|
res.json(runs);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============ PUBLIC STATUS ROUTE (sans authentification) ============
|
// ============ PUBLIC STATUS ROUTE (sans authentification) ============
|
||||||
// Utilisé par le portail applicatif pour griser les tuiles des apps arrêtées
|
// Utilisé par le portail applicatif pour griser les tuiles des apps arrêtées
|
||||||
router.get('/public/status', async (req, res) => {
|
router.get('/public/status', async (req, res) => {
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ const { execFile } = require('child_process');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const { findAppDirectoryByRepo } = require('./app-registry');
|
const { findAppByRepo } = require('./app-registry');
|
||||||
|
const { getWorkflowRunForCommit } = require('./gitea');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ const DEPLOY_SCRIPT = '/opt/manus-deploy/scripts/deploy-app.sh';
|
|||||||
const LOG_DIR = '/var/log/manus-deploy';
|
const LOG_DIR = '/var/log/manus-deploy';
|
||||||
const DASHBOARD_APP_ID = 'manus-dashboard';
|
const DASHBOARD_APP_ID = 'manus-dashboard';
|
||||||
const DASHBOARD_DEPLOY_REQUEST = path.join(APPS_BASE_PATH, DASHBOARD_APP_ID, '.deployment-request');
|
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)
|
// Déploiements en cours (évite les doubles déclenchements)
|
||||||
const deployingApps = new Set();
|
const deployingApps = new Set();
|
||||||
@@ -37,6 +40,46 @@ function requestDashboardHostDeployment({ branch, commitHash, committer }) {
|
|||||||
fs.renameSync(temporaryRequest, DASHBOARD_DEPLOY_REQUEST);
|
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) {
|
function verifyGiteaSignature(req) {
|
||||||
if (!WEBHOOK_SECRET) {
|
if (!WEBHOOK_SECRET) {
|
||||||
console.warn('[Webhook] AVERTISSEMENT: WEBHOOK_SECRET non défini. Validation désactivée.');
|
console.warn('[Webhook] AVERTISSEMENT: WEBHOOK_SECRET non défini. Validation désactivée.');
|
||||||
@@ -86,16 +129,30 @@ function runDeploy(appName, branch, commitHash, committer, broadcast) {
|
|||||||
if (broadcast) {
|
if (broadcast) {
|
||||||
broadcast({ type: 'deploy_finished', data: { app: appName, status, exitCode: code } });
|
broadcast({ type: 'deploy_finished', data: { app: appName, status, exitCode: code } });
|
||||||
}
|
}
|
||||||
try {
|
saveDeploymentStatus(appName, {
|
||||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
app: appName, branch, commit: commitHash, committer, status, exitCode: code,
|
||||||
fs.writeFileSync(path.join(LOG_DIR, `${appName}-last-deploy.json`), JSON.stringify({
|
timestamp: new Date().toISOString(), output: output.slice(-3000),
|
||||||
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); }
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// POST /api/webhook/gitea
|
||||||
router.post('/gitea', (req, res) => {
|
router.post('/gitea', (req, res) => {
|
||||||
if (!verifyGiteaSignature(req)) {
|
if (!verifyGiteaSignature(req)) {
|
||||||
@@ -115,20 +172,20 @@ router.post('/gitea', (req, res) => {
|
|||||||
console.log(`[Webhook] Push: repo=${repoName}, branch=${branch}, commit=${commitHash}, by=${committer}`);
|
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.
|
// Le manifeste app.json associe le dépôt au dossier déployé : pas de mapping statique à maintenir.
|
||||||
const appName = findAppDirectoryByRepo(config, repoName);
|
const app = findAppByRepo(config, repoName);
|
||||||
if (!appName) return res.status(200).json({ message: `Dépôt ${repoName} non déployé ou sans manifeste valide` });
|
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` });
|
if (branch !== 'main') return res.status(200).json({ message: `Branch ${branch} ignorée` });
|
||||||
|
|
||||||
if (isHostManagedDeployment(appName)) {
|
res.status(202).json({
|
||||||
requestDashboardHostDeployment({ branch, commitHash, committer });
|
message: shouldRequireCi(app) ? `Validation CI requise avant déploiement de ${app.directory}` : `Déploiement de ${app.directory} déclenché`,
|
||||||
return res.status(202).json({ message: 'Redéploiement du dashboard confié au service hôte', commit: commitHash, branch });
|
commit: commitHash,
|
||||||
}
|
branch,
|
||||||
|
ciRequired: shouldRequireCi(app),
|
||||||
res.status(202).json({ message: `Déploiement de ${appName} déclenché`, commit: commitHash, branch, committer });
|
});
|
||||||
|
|
||||||
// Récupérer la fonction broadcast si disponible globalement
|
// Récupérer la fonction broadcast si disponible globalement
|
||||||
const broadcastFn = global.wsBroadcast || null;
|
const broadcastFn = global.wsBroadcast || null;
|
||||||
setImmediate(() => runDeploy(appName, branch, commitHash, committer, broadcastFn));
|
setImmediate(() => gateAndDeploy(app, branch, commitHash, committer, broadcastFn));
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/webhook/status/:appName
|
// GET /api/webhook/status/:appName
|
||||||
@@ -157,3 +214,5 @@ router.get('/status', (req, res) => {
|
|||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
module.exports.isHostManagedDeployment = isHostManagedDeployment;
|
module.exports.isHostManagedDeployment = isHostManagedDeployment;
|
||||||
|
module.exports.shouldRequireCi = shouldRequireCi;
|
||||||
|
module.exports.waitForSuccessfulCi = waitForSuccessfulCi;
|
||||||
|
|||||||
18
src/backend/test/gitea.test.js
Normal file
18
src/backend/test/gitea.test.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { normalizeWorkflowRun } = require('../src/gitea');
|
||||||
|
|
||||||
|
const run = normalizeWorkflowRun({
|
||||||
|
id: 42,
|
||||||
|
name: 'Validation',
|
||||||
|
status: 'completed',
|
||||||
|
conclusion: 'success',
|
||||||
|
head_sha: 'abcdef123456',
|
||||||
|
created_at: '2026-08-18T10:00:00Z',
|
||||||
|
run_started_at: '2026-08-18T10:00:10Z',
|
||||||
|
updated_at: '2026-08-18T10:02:15Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(run.commit, 'abcdef123456');
|
||||||
|
assert.equal(run.durationSeconds, 125);
|
||||||
|
assert.equal(run.conclusion, 'success');
|
||||||
|
console.log('OK gitea');
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const { isHostManagedDeployment } = require('../src/webhook');
|
const { isHostManagedDeployment, shouldRequireCi } = require('../src/webhook');
|
||||||
|
|
||||||
assert.equal(isHostManagedDeployment('manus-dashboard'), true);
|
assert.equal(isHostManagedDeployment('manus-dashboard'), true);
|
||||||
assert.equal(isHostManagedDeployment('itinova-contacts'), false);
|
assert.equal(isHostManagedDeployment('itinova-contacts'), false);
|
||||||
|
assert.equal(shouldRequireCi({ ci: { required: true } }), true);
|
||||||
|
assert.equal(shouldRequireCi({ ci: { required: false } }), false);
|
||||||
|
assert.equal(shouldRequireCi({}), false);
|
||||||
console.log('OK webhook');
|
console.log('OK webhook');
|
||||||
|
|||||||
3030
src/frontend/package-lock.json
generated
Normal file
3030
src/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user