Sécuriser Gitea et alerter sur les échecs CI

This commit is contained in:
Manus Déploiement Santinova
2026-08-21 16:16:46 +00:00
parent 1f1d525ea1
commit a37e29e42a
27 changed files with 819 additions and 74 deletions

View File

@@ -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",

View File

@@ -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 {

View 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 };

View File

@@ -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',

View File

@@ -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,

View File

@@ -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

View File

@@ -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 };
})

View File

@@ -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é` });
}

View 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');

View 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');

View 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');

View 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');

View 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');

View File

@@ -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

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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">

View File

@@ -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');