Sécuriser Gitea et alerter sur les échecs CI
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"start": "node 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 && node test/gitea.test.js"
|
||||
"test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/webhook.test.js && node test/gitea.test.js && node test/gitea-auth.test.js && node test/token-rotation.test.js && node test/ci-alerts.test.js && node test/webhook-setup.test.js && node test/dashboard-alert-token.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { exec, execSync } = require('child_process');
|
||||
const config = require('./config');
|
||||
const { createRepo, giteaClient } = require('./gitea');
|
||||
const { refreshApps, writeAppManifest } = require('./app-registry');
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
async function createGiteaGitAuth() {
|
||||
if (!config.gitea.token) throw new Error('GITEA_TOKEN est requis pour les opérations Git.');
|
||||
const directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'dashboard-gitea-auth-'));
|
||||
const tokenPath = path.join(directory, 'token');
|
||||
const askpassPath = path.join(directory, 'askpass.sh');
|
||||
await fsp.writeFile(tokenPath, config.gitea.token, { mode: 0o600 });
|
||||
await fsp.writeFile(askpassPath, `#!/bin/sh\ncase "$1" in\n *Username*|*username*) printf '%s\\n' '${config.gitea.username}' ;;\n *) cat '${tokenPath}' ;;\nesac\n`, { mode: 0o700 });
|
||||
return {
|
||||
env: { ...process.env, GIT_ASKPASS: askpassPath, GIT_TERMINAL_PROMPT: '0' },
|
||||
cleanup: () => fsp.rm(directory, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGiteaRepoUrl(value) {
|
||||
const url = new URL(value);
|
||||
const configured = new URL(config.gitea.url);
|
||||
if (url.protocol !== 'https:' || url.hostname !== configured.hostname) {
|
||||
throw new Error('Le dépôt doit utiliser le serveur Gitea Santinova configuré en HTTPS.');
|
||||
}
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// ============ TEMPLATES DOCKERFILE ============
|
||||
|
||||
const DOCKERFILE_TEMPLATES = {
|
||||
@@ -436,6 +465,7 @@ async function createApplication(params, logCallback) {
|
||||
const domain = `${subdomain}.recette.santinova-soft.org`;
|
||||
const appDir = path.join(config.appsBasePath, appId);
|
||||
const srcDir = path.join(appDir, 'src');
|
||||
let gitAuth = null;
|
||||
|
||||
const log = (msg) => {
|
||||
const line = `[${new Date().toISOString()}] ${msg}`;
|
||||
@@ -444,6 +474,7 @@ async function createApplication(params, logCallback) {
|
||||
};
|
||||
|
||||
try {
|
||||
gitAuth = await createGiteaGitAuth();
|
||||
// 1. Créer le répertoire de l'application
|
||||
log(`Création du répertoire ${appDir}...`);
|
||||
await fsp.mkdir(appDir, { recursive: true });
|
||||
@@ -455,11 +486,8 @@ async function createApplication(params, logCallback) {
|
||||
if (sourceType === 'gitea') {
|
||||
// 2a. Cloner depuis un dépôt Gitea existant
|
||||
log(`Clonage du dépôt Gitea: ${giteaRepoUrl}...`);
|
||||
const cloneUrl = giteaRepoUrl.replace(
|
||||
'https://',
|
||||
`https://${config.gitea.username}:${encodeURIComponent(config.gitea.password)}@`
|
||||
);
|
||||
await execCommand(`cd ${appDir} && rm -rf src && git clone ${cloneUrl} src`, 120000);
|
||||
const cloneUrl = normalizeGiteaRepoUrl(giteaRepoUrl);
|
||||
await execCommand(`cd ${shellQuote(appDir)} && rm -rf src && git clone ${shellQuote(cloneUrl)} src`, 120000, gitAuth.env);
|
||||
log('Clonage terminé.');
|
||||
|
||||
// Extraire le nom du repo depuis l'URL
|
||||
@@ -503,10 +531,11 @@ async function createApplication(params, logCallback) {
|
||||
|
||||
// Initialiser git et pousser le code
|
||||
log('Initialisation Git et push vers Gitea...');
|
||||
const gitUrl = `https://${config.gitea.username}:${encodeURIComponent(config.gitea.password)}@${config.gitea.url.replace('https://', '')}/${config.gitea.username}/${appId}.git`;
|
||||
const gitUrl = `${config.gitea.url.replace(/\/$/, '')}/${config.gitea.username}/${appId}.git`;
|
||||
await execCommand(
|
||||
`cd ${srcDir} && git init && git checkout -b main && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add -A && git commit -m "Initial commit: ${name}" && git remote add origin ${gitUrl} && git push -u origin main --force`,
|
||||
120000
|
||||
`cd ${shellQuote(srcDir)} && git init && git checkout -b main && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add -A && git commit -m ${shellQuote(`Initial commit: ${name}`)} && git remote add origin ${shellQuote(gitUrl)} && git push -u origin main --force`,
|
||||
120000,
|
||||
gitAuth.env
|
||||
);
|
||||
log('Code poussé sur Gitea.');
|
||||
}
|
||||
@@ -548,7 +577,8 @@ async function createApplication(params, logCallback) {
|
||||
if (sourceType === 'zip') {
|
||||
await execCommand(
|
||||
`cd ${srcDir} && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add Dockerfile && git commit -m "Add auto-generated Dockerfile" && git push origin main`,
|
||||
60000
|
||||
60000,
|
||||
gitAuth.env
|
||||
);
|
||||
log('Dockerfile commité et poussé sur Gitea.');
|
||||
}
|
||||
@@ -642,12 +672,14 @@ async function createApplication(params, logCallback) {
|
||||
} catch (err) {
|
||||
log(`ERREUR: ${err.message}`);
|
||||
throw err;
|
||||
} finally {
|
||||
await gitAuth?.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function execCommand(cmd, timeout = 60000) {
|
||||
function execCommand(cmd, timeout = 60000, env = process.env) {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(cmd, { maxBuffer: 1024 * 1024 * 50, timeout }, (error, stdout, stderr) => {
|
||||
exec(cmd, { maxBuffer: 1024 * 1024 * 50, timeout, env }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`Commande échouée: ${error.message}\nStdout: ${stdout}\nStderr: ${stderr}`));
|
||||
} else {
|
||||
|
||||
82
src/backend/src/ci-alerts.js
Normal file
82
src/backend/src/ci-alerts.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const config = require('./config');
|
||||
|
||||
const LOG_DIR = '/var/log/manus-deploy';
|
||||
const ALERTS_FILE = path.join(LOG_DIR, 'ci-alerts.json');
|
||||
const MAX_ALERTS = 100;
|
||||
|
||||
function normalizeWorkflowFailure(payload, deliveryId = null) {
|
||||
if (payload?.action !== 'completed') return null;
|
||||
const run = payload?.workflow_run || {};
|
||||
const conclusion = String(run.conclusion || '').toLowerCase();
|
||||
if (!conclusion || conclusion === 'success') return null;
|
||||
const repository = payload?.repository?.full_name || payload?.repository?.name || 'dépôt inconnu';
|
||||
const commit = run.head_sha || run.head_commit?.id || 'commit-inconnu';
|
||||
return {
|
||||
id: deliveryId || `${repository}-${run.id || commit}-${run.completed_at || Date.now()}`,
|
||||
repository,
|
||||
workflow: payload?.workflow?.name || run.name || run.workflow_id || 'Validation CI',
|
||||
commit,
|
||||
conclusion,
|
||||
url: run.html_url || null,
|
||||
occurredAt: run.completed_at || new Date().toISOString(),
|
||||
receivedAt: new Date().toISOString(),
|
||||
dsiNotificationSent: false,
|
||||
};
|
||||
}
|
||||
|
||||
function readCiAlerts() {
|
||||
try {
|
||||
const value = JSON.parse(fs.readFileSync(ALERTS_FILE, 'utf8'));
|
||||
return Array.isArray(value) ? value : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveCiAlert(alert) {
|
||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
const alerts = readCiAlerts().filter((candidate) => candidate.id !== alert.id);
|
||||
alerts.unshift(alert);
|
||||
const temporary = `${ALERTS_FILE}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporary, JSON.stringify(alerts.slice(0, MAX_ALERTS), null, 2), { mode: 0o640 });
|
||||
fs.renameSync(temporary, ALERTS_FILE);
|
||||
return alert;
|
||||
}
|
||||
|
||||
async function notifyDsi(alert) {
|
||||
if (!config.dsiAlerts.token) {
|
||||
console.error('[CI Alert] IA_REFERENTIEL_TOKEN absent : notification DSI non envoyée.');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${config.dsiAlerts.url.replace(/\/$/, '')}/api/integration/v1/alerts/ci`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${config.dsiAlerts.token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repository: alert.repository,
|
||||
workflow: alert.workflow,
|
||||
commit: alert.commit,
|
||||
conclusion: alert.conclusion,
|
||||
url: alert.url,
|
||||
occurredAt: alert.occurredAt,
|
||||
deliveryId: alert.id,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`[CI Alert] Notification DSI refusée (${response.status}).`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[CI Alert] Notification DSI impossible:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { normalizeWorkflowFailure, readCiAlerts, saveCiAlert, notifyDsi };
|
||||
@@ -25,8 +25,14 @@ module.exports = {
|
||||
gitea: {
|
||||
url: process.env.GITEA_URL || 'http://gitea:3000',
|
||||
username: process.env.GITEA_USERNAME || 'manus-admin',
|
||||
password: process.env.GITEA_PASSWORD || 'Itinova69!',
|
||||
token: process.env.GITEA_TOKEN || null,
|
||||
productionUrl: process.env.GITEA_PROD_URL || 'https://git.santinova-soft.org',
|
||||
productionToken: process.env.GITEA_TOKEN_PROD || null,
|
||||
},
|
||||
|
||||
dsiAlerts: {
|
||||
url: process.env.IA_REFERENTIEL_URL || 'https://referentiel-ia.recette.santinova-soft.org',
|
||||
token: process.env.IA_REFERENTIEL_TOKEN || null,
|
||||
},
|
||||
|
||||
appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps',
|
||||
|
||||
@@ -2,18 +2,9 @@ const axios = require('axios');
|
||||
const https = require('https');
|
||||
const config = require('./config');
|
||||
|
||||
// Créer un client axios pour Gitea (ignorer les certificats auto-signés si nécessaire)
|
||||
// Utiliser le token API si disponible, sinon auth basique
|
||||
const giteaClientHeaders = config.gitea.token
|
||||
? { 'Authorization': `token ${config.gitea.token}` }
|
||||
: {};
|
||||
|
||||
const giteaClient = axios.create({
|
||||
baseURL: `${config.gitea.url}/api/v1`,
|
||||
...(config.gitea.token
|
||||
? { headers: giteaClientHeaders }
|
||||
: { auth: { username: config.gitea.username, password: config.gitea.password } }
|
||||
),
|
||||
headers: config.gitea.token ? { Authorization: `token ${config.gitea.token}` } : {},
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
timeout: 10000,
|
||||
});
|
||||
@@ -155,6 +146,35 @@ async function getWorkflowRunSummary(owner, repo) {
|
||||
return calculateCiSummary(await getWorkflowRuns(owner, repo, 30));
|
||||
}
|
||||
|
||||
async function getRepoSnapshot(baseUrl, owner, repo, token, publicBaseUrl = baseUrl) {
|
||||
if (!token) return { present: false, url: null, version: null };
|
||||
const client = axios.create({
|
||||
baseURL: `${baseUrl}/api/v1`,
|
||||
headers: { Authorization: `token ${token}` },
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
timeout: 7000,
|
||||
});
|
||||
try {
|
||||
const [repoResponse, commitsResponse] = await Promise.all([
|
||||
client.get(`/repos/${owner}/${repo}`),
|
||||
client.get(`/repos/${owner}/${repo}/commits`, { params: { limit: 1 } }),
|
||||
]);
|
||||
const commit = commitsResponse.data?.[0];
|
||||
const sha = commit?.sha?.substring(0, 7) || null;
|
||||
const date = commit?.commit?.author?.date
|
||||
? new Date(commit.commit.author.date).toLocaleDateString('fr-FR')
|
||||
: null;
|
||||
return {
|
||||
present: Boolean(repoResponse.data?.id),
|
||||
url: `${publicBaseUrl}/${owner}/${repo}`,
|
||||
version: sha && date ? `${sha} (${date})` : sha,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Erreur Gitea getRepoSnapshot ${owner}/${repo}:`, error.message);
|
||||
return { present: false, url: null, version: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer tous les dépôts (avec retry en cas d'erreur DNS)
|
||||
*/
|
||||
@@ -201,6 +221,7 @@ module.exports = {
|
||||
getWorkflowRuns,
|
||||
getWorkflowRunSummary,
|
||||
getWorkflowRunForCommit,
|
||||
getRepoSnapshot,
|
||||
normalizeWorkflowRun,
|
||||
calculateCiSummary,
|
||||
listRepos,
|
||||
|
||||
@@ -99,6 +99,9 @@ async function runHealthChecks() {
|
||||
|
||||
// Démarrage
|
||||
async function start() {
|
||||
if (!config.gitea.token) {
|
||||
throw new Error('GITEA_TOKEN est requis : aucun retour à une authentification par mot de passe n’est autorisé.');
|
||||
}
|
||||
await initAuth();
|
||||
|
||||
// Charger les applications dynamiques
|
||||
|
||||
@@ -2,11 +2,13 @@ const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execFile } = require('child_process');
|
||||
const { authenticate, authMiddleware } = require('./auth');
|
||||
const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker');
|
||||
const { getRepo, getCommits, getBranches, getWorkflowRuns, getWorkflowRunSummary, listRepos } = require('./gitea');
|
||||
const { getRepo, getCommits, getBranches, getWorkflowRuns, getWorkflowRunSummary, getRepoSnapshot, listRepos } = require('./gitea');
|
||||
const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck');
|
||||
const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator');
|
||||
const { readCiAlerts } = require('./ci-alerts');
|
||||
const config = require('./config');
|
||||
|
||||
// Nettoyer les déploiements orphelins au démarrage (entrées "running" restantes d'un crash précédent)
|
||||
@@ -541,17 +543,63 @@ router.get('/system/metrics', authMiddleware, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/ci-alerts', authMiddleware, (req, res) => {
|
||||
res.json(readCiAlerts());
|
||||
});
|
||||
|
||||
const GITEA_ROTATION_SCRIPT = '/opt/manus-deploy/scripts/gitea-token-rotation.sh';
|
||||
const GITEA_ROTATION_STATUS_FILE = '/var/log/manus-deploy/gitea-token-rotation.json';
|
||||
const GITEA_ROTATION_APPROVAL_FILE = '/opt/manus-deploy/secrets/gitea-token-rotation/approval.request';
|
||||
|
||||
function readGiteaRotationStatus() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(GITEA_ROTATION_STATUS_FILE, 'utf8'));
|
||||
} catch {
|
||||
return { state: 'not_configured', message: 'Aucune rotation n’a encore été préparée.', tokenName: '', updatedAt: null };
|
||||
}
|
||||
}
|
||||
|
||||
function runGiteaRotation(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(GITEA_ROTATION_SCRIPT, args, { timeout: 120000 }, (error, stdout, stderr) => {
|
||||
if (error) return reject(new Error((stderr || stdout || error.message).trim()));
|
||||
resolve(stdout.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/security/gitea-token-rotation', authMiddleware, (req, res) => {
|
||||
res.json(readGiteaRotationStatus());
|
||||
});
|
||||
|
||||
router.post('/security/gitea-token-rotation/prepare', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await runGiteaRotation(['prepare', '--force']);
|
||||
res.json(readGiteaRotationStatus());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message, status: readGiteaRotationStatus() });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/security/gitea-token-rotation/approve', authMiddleware, (req, res) => {
|
||||
const status = readGiteaRotationStatus();
|
||||
if (status.state !== 'pending_approval') {
|
||||
return res.status(409).json({ error: 'Aucun jeton validé n’attend une approbation.', status });
|
||||
}
|
||||
fs.mkdirSync(path.dirname(GITEA_ROTATION_APPROVAL_FILE), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(GITEA_ROTATION_APPROVAL_FILE, `${new Date().toISOString()}\n`, { mode: 0o600 });
|
||||
res.status(202).json({ ...status, state: 'approval_requested', message: 'Basculement demandé à l’hôte. Le dashboard sera brièvement redémarré après validation.' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
// ===== INVENTAIRE DES APPLICATIONS =====
|
||||
const { exec } = require('child_process');
|
||||
const GITEA_RECETTE_INTERNAL = process.env.GITEA_RECETTE_URL || 'http://gitea:3000';
|
||||
const GITEA_RECETTE_PUBLIC = process.env.GITEA_RECETTE_PUBLIC_URL || 'https://git.recette.santinova-soft.org';
|
||||
const GITEA_PROD_EXTERNAL = 'https://git.santinova-soft.org';
|
||||
const GITEA_PROD_EXTERNAL = config.gitea.productionUrl;
|
||||
const GITEA_USER_INV = process.env.GITEA_USERNAME || 'manus-admin';
|
||||
const GITEA_PASS_REC = process.env.GITEA_PASSWORD || 'Itinova69!';
|
||||
const GITEA_PASS_PRD = process.env.GITEA_PASSWORD_PROD || 'ManusGitea2026!';
|
||||
const GITEA_TOKEN_REC = process.env.GITEA_RECETTE_TOKEN || process.env.GITEA_TOKEN || null;
|
||||
const GITEA_TOKEN_PRD = config.gitea.productionToken;
|
||||
|
||||
// INVENTORY_APPS est généré dynamiquement depuis config.apps (lui-même alimenté par la découverte des app.json)
|
||||
function getInventoryApps() {
|
||||
@@ -562,41 +610,6 @@ function getInventoryApps() {
|
||||
}));
|
||||
}
|
||||
|
||||
function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) {
|
||||
return new Promise((resolve) => {
|
||||
const authHeader = token
|
||||
? `-H "Authorization: token ${token}"`
|
||||
: `-u "${GITEA_USER_INV}:${pass}"`;
|
||||
const cmd = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}"`;
|
||||
exec(cmd, { timeout: 7000 }, (err, stdout) => {
|
||||
if (err || !stdout) return resolve({ present: false, url: null, version: null });
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
if (!data.id) return resolve({ present: false, url: null, version: null });
|
||||
// Récupérer le dernier commit
|
||||
const cmd2 = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}/commits?limit=1"`;
|
||||
exec(cmd2, { timeout: 7000 }, (err2, stdout2) => {
|
||||
let version = null;
|
||||
try {
|
||||
const commits = JSON.parse(stdout2 || '[]');
|
||||
if (commits.length > 0) {
|
||||
const c = commits[0];
|
||||
const sha = c.sha ? c.sha.substring(0, 7) : null;
|
||||
const date = c.commit && c.commit.author && c.commit.author.date
|
||||
? new Date(c.commit.author.date).toLocaleDateString('fr-FR') : null;
|
||||
version = sha && date ? `${sha} (${date})` : sha;
|
||||
}
|
||||
} catch(e) {}
|
||||
const displayUrl = publicBaseUrl || baseUrl;
|
||||
resolve({ present: true, url: `${displayUrl}/${owner}/${repo}`, version });
|
||||
});
|
||||
} catch(e) {
|
||||
resolve({ present: false, url: null, version: null });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/inventory', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const owner = GITEA_USER_INV;
|
||||
@@ -604,8 +617,8 @@ router.get('/inventory', authMiddleware, async (req, res) => {
|
||||
const results = await Promise.all(
|
||||
INVENTORY_APPS.map(async (app) => {
|
||||
const [repoRecette, repoProd] = await Promise.all([
|
||||
curlGitea(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_PASS_REC, GITEA_RECETTE_PUBLIC, GITEA_TOKEN_REC),
|
||||
curlGitea(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_PASS_PRD),
|
||||
getRepoSnapshot(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_TOKEN_REC, GITEA_RECETTE_PUBLIC),
|
||||
getRepoSnapshot(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_TOKEN_PRD),
|
||||
]);
|
||||
return { ...app, repoRecette, repoProd };
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ 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();
|
||||
|
||||
@@ -160,6 +161,22 @@ router.post('/gitea', (req, res) => {
|
||||
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é` });
|
||||
}
|
||||
|
||||
22
src/backend/test/ci-alerts.test.js
Normal file
22
src/backend/test/ci-alerts.test.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const assert = require('assert');
|
||||
const { normalizeWorkflowFailure } = require('../src/ci-alerts');
|
||||
|
||||
const failure = normalizeWorkflowFailure({
|
||||
action: 'completed',
|
||||
repository: { full_name: 'manus-admin/referentiel-ia' },
|
||||
workflow: { name: 'Validation CI' },
|
||||
workflow_run: {
|
||||
id: 42,
|
||||
conclusion: 'failure',
|
||||
head_sha: '1234567890abcdef',
|
||||
completed_at: '2026-08-21T12:00:00.000Z',
|
||||
html_url: 'https://git.recette.santinova-soft.org/actions/runs/42',
|
||||
},
|
||||
}, 'delivery-42');
|
||||
|
||||
assert.equal(failure.id, 'delivery-42');
|
||||
assert.equal(failure.repository, 'manus-admin/referentiel-ia');
|
||||
assert.equal(failure.conclusion, 'failure');
|
||||
assert.equal(normalizeWorkflowFailure({ action: 'completed', workflow_run: { conclusion: 'success' } }), null);
|
||||
assert.equal(normalizeWorkflowFailure({ action: 'in_progress', workflow_run: { conclusion: 'failure' } }), null);
|
||||
console.log('OK ci alerts');
|
||||
13
src/backend/test/dashboard-alert-token.test.js
Normal file
13
src/backend/test/dashboard-alert-token.test.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '../../..');
|
||||
const source = fs.readFileSync(path.join(root, 'ops/provision-dashboard-alert-token.sh'), 'utf8');
|
||||
|
||||
assert.match(source, /dashboard-alert-token\.ts prepare/);
|
||||
assert.match(source, /alerts\/ci\/health/);
|
||||
assert.match(source, /dashboard-alert-token\.ts finalize/);
|
||||
assert.match(source, /rollback effectué/);
|
||||
assert.doesNotMatch(source, /GITEA_PASSWORD/);
|
||||
console.log('OK dashboard alert token');
|
||||
23
src/backend/test/gitea-auth.test.js
Normal file
23
src/backend/test/gitea-auth.test.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const files = [
|
||||
'backend/src/config.js',
|
||||
'backend/src/gitea.js',
|
||||
'backend/src/app-creator.js',
|
||||
'backend/src/routes.js',
|
||||
'docker-compose.yml',
|
||||
];
|
||||
|
||||
for (const relativePath of files) {
|
||||
const content = fs.readFileSync(path.join(root, relativePath), 'utf8');
|
||||
assert.equal(content.includes('GITEA_PASSWORD'), false, `${relativePath} contient encore GITEA_PASSWORD`);
|
||||
assert.equal(content.includes('config.gitea.password'), false, `${relativePath} utilise encore le mot de passe Gitea`);
|
||||
}
|
||||
|
||||
const compose = fs.readFileSync(path.resolve(root, '../docker-compose.yml'), 'utf8');
|
||||
assert.match(compose, /GITEA_TOKEN=\$\{GITEA_TOKEN:\?/);
|
||||
assert.equal(/GITEA_TOKEN=[a-f0-9]{20,}/.test(compose), false);
|
||||
console.log('OK gitea auth');
|
||||
19
src/backend/test/token-rotation.test.js
Normal file
19
src/backend/test/token-rotation.test.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '../../..');
|
||||
const rotation = fs.readFileSync(path.join(root, 'ops/gitea-token-rotation.sh'), 'utf8');
|
||||
const timer = fs.readFileSync(path.join(root, 'ops/gitea-token-rotation.timer'), 'utf8');
|
||||
const approvalPath = fs.readFileSync(path.join(root, 'ops/gitea-token-rotation-apply.path'), 'utf8');
|
||||
|
||||
assert.match(rotation, /generate-access-token/);
|
||||
assert.match(rotation, /pending_approval/);
|
||||
assert.match(rotation, /GITEA_ROTATION_APPLIED/);
|
||||
assert.match(rotation, /chmod 0600/);
|
||||
assert.match(rotation, /rollback/);
|
||||
assert.doesNotMatch(rotation, /GITEA_PASSWORD/);
|
||||
assert.match(timer, /OnCalendar=\*-01,04,07,10-01 07:00:00/);
|
||||
assert.match(timer, /Persistent=true/);
|
||||
assert.match(approvalPath, /PathExists=.*approval\.request/);
|
||||
console.log('OK token rotation');
|
||||
14
src/backend/test/webhook-setup.test.js
Normal file
14
src/backend/test/webhook-setup.test.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '../../..');
|
||||
const setup = fs.readFileSync(path.join(root, 'ops/setup-gitea-webhooks.py'), 'utf8');
|
||||
const compose = fs.readFileSync(path.join(root, 'docker-compose.yml'), 'utf8');
|
||||
|
||||
assert.match(setup, /"events": \["push", "workflow_run"\]/);
|
||||
assert.match(setup, /secrets\.token_hex\(32\)/);
|
||||
assert.match(setup, /method=method/);
|
||||
assert.doesNotMatch(setup, /GITEA_PASSWORD/);
|
||||
assert.match(compose, /WEBHOOK_SECRET=\$\{WEBHOOK_SECRET:\?/);
|
||||
console.log('OK webhook setup');
|
||||
@@ -13,10 +13,12 @@ services:
|
||||
- ADMIN_PASSWORD=Itinova69!
|
||||
- GITEA_URL=https://git.recette.santinova-soft.org
|
||||
- GITEA_USERNAME=manus-admin
|
||||
- GITEA_PASSWORD=ManusGitea2026!
|
||||
- GITEA_TOKEN=1e0b01c361a91d5512e13c78053ac82a66e19427
|
||||
- GITEA_TOKEN=${GITEA_TOKEN:?GITEA_TOKEN requis dans le fichier .env serveur}
|
||||
- GITEA_RECETTE_URL=http://gitea:3000
|
||||
- GITEA_PASSWORD_PROD=ManusGitea2026!
|
||||
- GITEA_TOKEN_PROD=${GITEA_TOKEN_PROD:-}
|
||||
- WEBHOOK_SECRET=${WEBHOOK_SECRET:?WEBHOOK_SECRET requis dans le fichier .env serveur}
|
||||
- IA_REFERENTIEL_URL=${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org}
|
||||
- IA_REFERENTIEL_TOKEN=${IA_REFERENTIEL_TOKEN:-}
|
||||
- APPS_BASE_PATH=/opt/manus-deploy/apps
|
||||
- INFRA_BASE_PATH=/opt/manus-deploy/infrastructure
|
||||
- HEALTH_CHECK_INTERVAL=30000
|
||||
|
||||
@@ -10,13 +10,15 @@ import DocumentationPage from './pages/DocumentationPage';
|
||||
import InventairePage from './pages/InventairePage';
|
||||
import TerminalPage from './pages/TerminalPage';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import CiAlertBanner from './components/CiAlertBanner';
|
||||
import useWebSocket from './hooks/useWebSocket';
|
||||
import { getMe, getApps, logout as apiLogout } from './utils/api';
|
||||
import { getMe, getApps, getCiAlerts, logout as apiLogout } from './utils/api';
|
||||
export default function App() {
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState('dashboard');
|
||||
const [apps, setApps] = useState([]);
|
||||
const [ciAlerts, setCiAlerts] = useState([]);
|
||||
// Vérifier l'authentification au chargement
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('dashboard_token');
|
||||
@@ -25,6 +27,7 @@ export default function App() {
|
||||
.then(() => {
|
||||
setAuthenticated(true);
|
||||
fetchApps();
|
||||
fetchCiAlerts();
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('dashboard_token');
|
||||
@@ -43,11 +46,22 @@ export default function App() {
|
||||
console.error('Erreur chargement apps:', err);
|
||||
}
|
||||
};
|
||||
const fetchCiAlerts = async () => {
|
||||
try {
|
||||
const response = await getCiAlerts();
|
||||
setCiAlerts(response.data);
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement alertes CI:', error);
|
||||
}
|
||||
};
|
||||
// WebSocket handler
|
||||
const handleWsMessage = useCallback((data) => {
|
||||
if (data.type === 'health_update') {
|
||||
setApps(data.data);
|
||||
}
|
||||
if (data.type === 'ci_alert') {
|
||||
setCiAlerts((current) => [data.data, ...current.filter((alert) => alert.id !== data.data.id)]);
|
||||
}
|
||||
}, []);
|
||||
const { connected: wsConnected } = useWebSocket(
|
||||
authenticated ? handleWsMessage : null
|
||||
@@ -55,6 +69,7 @@ export default function App() {
|
||||
const handleLogin = (data) => {
|
||||
setAuthenticated(true);
|
||||
fetchApps();
|
||||
fetchCiAlerts();
|
||||
};
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
@@ -65,6 +80,7 @@ export default function App() {
|
||||
localStorage.removeItem('dashboard_token');
|
||||
setAuthenticated(false);
|
||||
setApps([]);
|
||||
setCiAlerts([]);
|
||||
};
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -126,7 +142,10 @@ export default function App() {
|
||||
onLogout={handleLogout}
|
||||
wsConnected={wsConnected}
|
||||
/>
|
||||
<main className="ml-64 p-8">{renderPage()}</main>
|
||||
<main className="ml-64 p-8">
|
||||
<CiAlertBanner alert={ciAlerts[0]} />
|
||||
{renderPage()}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
24
src/frontend/src/components/CiAlertBanner.jsx
Normal file
24
src/frontend/src/components/CiAlertBanner.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { AlertTriangle, ExternalLink } from 'lucide-react';
|
||||
|
||||
export default function CiAlertBanner({ alert }) {
|
||||
if (!alert) return null;
|
||||
return (
|
||||
<div className="mb-6 rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-red-100 shadow-lg shadow-red-950/20" role="alert">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-red-300" />
|
||||
<div>
|
||||
<p className="font-semibold">Échec CI — {alert.repository}</p>
|
||||
<p className="mt-1 text-sm text-red-100/80">{alert.workflow} · {alert.commit?.slice(0, 12)} · {alert.conclusion}</p>
|
||||
<p className="mt-1 text-xs text-red-100/60">Notification DSI : {alert.dsiNotificationSent ? 'envoyée' : 'en attente ou indisponible'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{alert.url && (
|
||||
<a href={alert.url} target="_blank" rel="noopener noreferrer" className="btn-secondary flex shrink-0 items-center gap-2">
|
||||
Voir le workflow <ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Minus,
|
||||
KeyRound,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { getGiteaRepos, getGiteaCommits, getGiteaWorkflowRuns, getGiteaWorkflowSummary } from '../utils/api';
|
||||
import { approveGiteaTokenRotation, getGiteaRepos, getGiteaCommits, getGiteaTokenRotation, getGiteaWorkflowRuns, getGiteaWorkflowSummary, prepareGiteaTokenRotation } from '../utils/api';
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
@@ -54,6 +56,34 @@ export default function GiteaPage() {
|
||||
const [workflowRuns, setWorkflowRuns] = useState([]);
|
||||
const [workflowSummary, setWorkflowSummary] = useState(null);
|
||||
const [loadingCommits, setLoadingCommits] = useState(false);
|
||||
const [rotation, setRotation] = useState(null);
|
||||
const [rotationBusy, setRotationBusy] = useState(false);
|
||||
const [rotationError, setRotationError] = useState('');
|
||||
|
||||
const fetchRotation = async () => {
|
||||
try {
|
||||
const response = await getGiteaTokenRotation();
|
||||
setRotation(response.data);
|
||||
} catch (error) {
|
||||
setRotationError(error.response?.data?.error || 'Statut de rotation indisponible.');
|
||||
}
|
||||
};
|
||||
|
||||
const runRotationAction = async (action) => {
|
||||
setRotationBusy(true);
|
||||
setRotationError('');
|
||||
try {
|
||||
const response = action === 'prepare'
|
||||
? await prepareGiteaTokenRotation()
|
||||
: await approveGiteaTokenRotation();
|
||||
setRotation(response.data);
|
||||
} catch (error) {
|
||||
setRotationError(error.response?.data?.error || 'Opération de rotation impossible.');
|
||||
if (error.response?.data?.status) setRotation(error.response.data.status);
|
||||
} finally {
|
||||
setRotationBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRepos = async () => {
|
||||
setLoading(true);
|
||||
@@ -92,6 +122,7 @@ export default function GiteaPage() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchRepos();
|
||||
fetchRotation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -124,6 +155,31 @@ export default function GiteaPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card p-5 border border-primary-500/20 bg-primary-500/5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-primary-500/10 p-2"><KeyRound className="h-5 w-5 text-primary-300" /></div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Rotation contrôlée du jeton Gitea</h3>
|
||||
<p className="mt-1 text-sm text-gray-400">{rotation?.message || 'Chargement du statut…'}</p>
|
||||
{rotation?.tokenName && <p className="mt-1 text-xs font-mono text-gray-500">{rotation.tokenName}</p>}
|
||||
{rotation?.updatedAt && <p className="mt-1 text-xs text-gray-500">Mis à jour : {formatDate(rotation.updatedAt)}</p>}
|
||||
{rotationError && <p className="mt-2 text-sm text-red-300">{rotationError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button disabled={rotationBusy} onClick={() => runRotationAction('prepare')} className="btn-secondary flex items-center gap-2 disabled:opacity-50">
|
||||
<RefreshCw className={`h-4 w-4 ${rotationBusy ? 'animate-spin' : ''}`} /> Préparer maintenant
|
||||
</button>
|
||||
{rotation?.state === 'pending_approval' && (
|
||||
<button disabled={rotationBusy} onClick={() => runRotationAction('approve')} className="btn-primary flex items-center gap-2 disabled:opacity-50">
|
||||
<ShieldCheck className="h-4 w-4" /> Approuver le basculement
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Repos List */}
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -54,6 +54,7 @@ export const createApp = (formData) =>
|
||||
// Deployments
|
||||
export const getDeployments = (appId) =>
|
||||
api.get('/deployments', { params: { appId } });
|
||||
export const getCiAlerts = () => api.get('/ci-alerts');
|
||||
|
||||
// Gitea
|
||||
export const getGiteaRepos = () => api.get('/gitea/repos');
|
||||
@@ -63,6 +64,9 @@ export const getGiteaWorkflowRuns = (owner, repo) =>
|
||||
api.get(`/gitea/repos/${owner}/${repo}/actions/runs`);
|
||||
export const getGiteaWorkflowSummary = (owner, repo) =>
|
||||
api.get(`/gitea/repos/${owner}/${repo}/actions/summary`);
|
||||
export const getGiteaTokenRotation = () => api.get('/security/gitea-token-rotation');
|
||||
export const prepareGiteaTokenRotation = () => api.post('/security/gitea-token-rotation/prepare');
|
||||
export const approveGiteaTokenRotation = () => api.post('/security/gitea-token-rotation/approve');
|
||||
|
||||
// Docker
|
||||
export const getContainers = () => api.get('/docker/containers');
|
||||
|
||||
Reference in New Issue
Block a user