refactor: fiabiliser le dashboard de production

This commit is contained in:
Manus
2026-08-18 07:38:46 +00:00
parent b4647580fb
commit e07e569559
12 changed files with 431 additions and 412 deletions

View File

@@ -5,7 +5,8 @@
"main": "src/index.js",
"scripts": {
"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"
},
"dependencies": {
"express": "^4.21.0",

View File

@@ -4,6 +4,7 @@ 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');
// ============ TEMPLATES DOCKERFILE ============
@@ -389,85 +390,29 @@ function getContainerPort(stack, customPort) {
return defaults[stack] || 3000;
}
// ============ APPS PERSISTENCE ============
const APPS_FILE = path.join(config.appsBasePath, '.dashboard-apps.json');
function loadDynamicApps() {
try {
if (fs.existsSync(APPS_FILE)) {
const data = fs.readFileSync(APPS_FILE, 'utf-8');
return JSON.parse(data);
}
} catch (err) {
console.error('Erreur chargement apps dynamiques:', err.message);
}
return [];
}
function saveDynamicApps(apps) {
try {
fs.writeFileSync(APPS_FILE, JSON.stringify(apps, null, 2), 'utf-8');
} catch (err) {
console.error('Erreur sauvegarde apps dynamiques:', err.message);
}
}
function addDynamicApp(appDef) {
const apps = loadDynamicApps();
// Éviter les doublons
const existing = apps.findIndex((a) => a.id === appDef.id);
if (existing >= 0) {
apps[existing] = appDef;
} else {
apps.push(appDef);
}
saveDynamicApps(apps);
// Ajouter aussi à config.apps en mémoire
const existingInConfig = config.apps.findIndex((a) => a.id === appDef.id);
if (existingInConfig >= 0) {
config.apps[existingInConfig] = appDef;
} else {
config.apps.push(appDef);
}
}
// ============ REGISTRE DES APPLICATIONS ============
/**
* Recharge le cache mémoire à partir des seuls manifests app.json.
* Les anciens fichiers de liste applicative ne sont plus lus : ils pouvaient
* afficher des applications non déployées et désynchroniser le portail.
*/
function initDynamicApps() {
// 1. Charger les apps depuis .dashboard-apps.json (legacy)
const dynamicApps = loadDynamicApps();
for (const app of dynamicApps) {
const existingInConfig = config.apps.findIndex((a) => a.id === app.id);
if (existingInConfig < 0) {
config.apps.push(app);
}
}
// 2. Scanner les dossiers app.json (découverte automatique)
try {
const appsBasePath = config.appsBasePath || process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps';
const entries = fs.readdirSync(appsBasePath, { withFileTypes: true });
let scanned = 0;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const appJsonPath = path.join(appsBasePath, entry.name, 'app.json');
if (!fs.existsSync(appJsonPath)) continue;
try {
const appDef = JSON.parse(fs.readFileSync(appJsonPath, 'utf-8'));
if (!appDef.id) continue;
const existingInConfig = config.apps.findIndex((a) => a.id === appDef.id);
if (existingInConfig < 0) {
config.apps.push(appDef);
scanned++;
}
} catch (e) {
console.error(`Erreur lecture app.json dans ${entry.name}:`, e.message);
}
}
console.log(`Apps découvertes via app.json: ${scanned}`);
} catch (err) {
console.error('Erreur scan app.json:', err.message);
}
console.log(`Total apps chargées: ${config.apps.length}`);
return refreshApps(config);
}
/**
* Publie le manifeste d'une application créée depuis le dashboard, puis
* rafraîchit immédiatement le registre partagé par le dashboard et le portail.
*/
function addDynamicApp(appDef) {
const app = writeAppManifest({
appsBasePath: config.appsBasePath,
environment: config.environment,
app: appDef,
});
refreshApps(config);
return app;
}
// ============ CREATION D'APPLICATION ============
@@ -481,6 +426,7 @@ async function createApplication(params, logCallback) {
port,
needsDb,
dbType,
category,
sourceType, // 'zip' or 'gitea'
giteaRepoUrl, // URL du repo Gitea existant
zipFilePath, // Chemin du fichier ZIP uploadé
@@ -678,6 +624,8 @@ async function createApplication(params, logCallback) {
stack: stack,
needsDb: needsDb || false,
dbType: dbType || null,
category: category || 'SANTINOVA',
image: `images/${appId}.png`,
createdAt: new Date().toISOString(),
};
@@ -730,7 +678,6 @@ module.exports = {
generateDockerCompose,
getAvailableStacks,
initDynamicApps,
loadDynamicApps,
addDynamicApp,
DOCKERFILE_TEMPLATES,
};

View File

@@ -0,0 +1,144 @@
/*
* Registre des applications déployées.
*
* Source de vérité : /opt/manus-deploy/apps/<dossier>/app.json.
* Le dashboard et le portail ne doivent jamais dépendre d'une liste applicative
* codée en dur : une application sans manifeste n'est pas considérée déployée.
*/
const fs = require('fs');
const path = require('path');
const CATEGORIES = new Set(['ITINOVA', 'SANTINOVA', 'INFRA']);
const ENVIRONMENTS = new Set(['recette', 'prod']);
function isHttpsUrl(value) {
if (typeof value !== 'string' || value.length === 0) return false;
try {
return new URL(value).protocol === 'https:';
} catch {
return false;
}
}
/**
* Valide puis normalise un manifeste app.json.
* Les champs dérivés (directory et healthCheckUrl) ne sont pas écrits dans le
* manifeste : ils sont calculés depuis le dossier et l'environnement courant.
*/
function normalizeManifest(manifest, directory, environment) {
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
throw new Error('le manifeste doit être un objet JSON');
}
if (!/^[a-z0-9][a-z0-9-]*$/.test(manifest.id || '')) {
throw new Error('id absent ou invalide');
}
if (typeof manifest.name !== 'string' || manifest.name.trim().length === 0) {
throw new Error('name absent ou invalide');
}
if (!CATEGORIES.has(manifest.category)) {
throw new Error('category doit être ITINOVA, SANTINOVA ou INFRA');
}
if (!manifest.urls || typeof manifest.urls !== 'object') {
throw new Error('urls absent ou invalide');
}
if (!isHttpsUrl(manifest.urls[environment])) {
throw new Error(`urls.${environment} absent ou invalide`);
}
return {
...manifest,
name: manifest.name.trim(),
directory,
containerName: manifest.containerName || manifest.id,
healthCheckUrl: manifest.urls[environment],
};
}
/**
* Découvre les applications depuis le système de fichiers sans modifier la
* configuration mémoire. Les erreurs de manifeste sont retournées explicitement
* afin qu'un manifeste défaillant ne fasse pas tomber le dashboard.
*/
function discoverApps({ appsBasePath, environment }) {
if (!ENVIRONMENTS.has(environment)) {
throw new Error(`Environnement non supporté : ${environment}`);
}
const apps = [];
const errors = [];
let entries = [];
try {
entries = fs.readdirSync(appsBasePath, { withFileTypes: true });
} catch (error) {
return { apps, errors: [{ directory: appsBasePath, error: error.message }] };
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const manifestPath = path.join(appsBasePath, entry.name, 'app.json');
if (!fs.existsSync(manifestPath)) continue;
try {
const raw = fs.readFileSync(manifestPath, 'utf8');
const manifest = normalizeManifest(JSON.parse(raw), entry.name, environment);
apps.push(manifest);
} catch (error) {
errors.push({ directory: entry.name, error: error.message });
}
}
apps.sort((left, right) => left.name.localeCompare(right.name, 'fr'));
return { apps, errors };
}
/**
* Actualise le cache mémoire utilisé par les routes et les contrôles de santé.
* Toute valeur obsolète est remplacée : seules les applications réellement
* déployées et correctement déclarées restent disponibles.
*/
function refreshApps(config, logger = console) {
const result = discoverApps({
appsBasePath: config.appsBasePath,
environment: config.environment,
});
config.apps.splice(0, config.apps.length, ...result.apps);
for (const issue of result.errors) {
logger.warn(`[app-registry] Manifeste ignoré (${issue.directory}) : ${issue.error}`);
}
logger.info(`[app-registry] ${result.apps.length} application(s) déployée(s) détectée(s)`);
return result;
}
/**
* Écrit un manifeste de manière atomique puis le valide avant publication.
*/
function writeAppManifest({ appsBasePath, environment, app }) {
const directory = app.directory || app.id;
const appDirectory = path.join(appsBasePath, directory);
const normalized = normalizeManifest({ ...app, directory: undefined, healthCheckUrl: undefined }, directory, environment);
const manifestPath = path.join(appDirectory, 'app.json');
const temporaryPath = `${manifestPath}.tmp`;
fs.mkdirSync(appDirectory, { recursive: true });
fs.writeFileSync(temporaryPath, `${JSON.stringify({ ...normalized, directory: undefined, healthCheckUrl: undefined }, null, 2)}\n`, 'utf8');
fs.renameSync(temporaryPath, manifestPath);
return normalized;
}
function findAppDirectoryByRepo(config, repositoryName) {
const { apps } = refreshApps(config);
const app = apps.find((candidate) => candidate.giteaRepo === repositoryName || candidate.id === repositoryName);
return app ? app.directory : null;
}
module.exports = {
discoverApps,
refreshApps,
writeAppManifest,
findAppDirectoryByRepo,
};

View File

@@ -1,230 +1,38 @@
/*
* Configuration du dashboard.
*
* Les métadonnées des applications ne sont volontairement pas définies ici.
* Elles sont lues exclusivement depuis /opt/manus-deploy/apps/<app>/app.json
* par app-registry.js, ce qui évite les divergences entre dashboard et portail.
*/
const environment = process.env.DASHBOARD_ENV || 'recette';
if (!['recette', 'prod'].includes(environment)) {
throw new Error('DASHBOARD_ENV doit valoir "recette" ou "prod"');
}
module.exports = {
port: process.env.PORT || 3001,
port: Number.parseInt(process.env.PORT, 10) || 3001,
jwtSecret: process.env.JWT_SECRET || 'manus-dashboard-secret-2026',
jwtExpiry: '24h',
// Authentification
environment,
auth: {
username: process.env.ADMIN_USERNAME || 'adminItinova',
password: process.env.ADMIN_PASSWORD || 'Itinova69!',
},
// Gitea
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,
},
// Applications config
appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps',
infrastructurePath: process.env.INFRA_BASE_PATH || '/opt/manus-deploy/infrastructure',
// Health check interval (ms)
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 30000,
// Apps configuration
apps: [
{
id: 'itinova-contacts',
name: 'Itinova Contacts',
description: 'Application de gestion des contacts Itinova',
directory: 'itinova-contacts',
giteaRepo: 'itinova-contacts',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://contacts.recette.santinova-soft.org',
prod: 'https://contacts.santinova-soft.org',
},
containerName: 'itinova-contacts',
healthCheckUrl: 'https://contacts.recette.santinova-soft.org',
port: 3000,
category: 'ITINOVA',
status: 'production',
},
{
id: 'itinova-podcasts',
name: 'Itinova Podcasts',
description: 'Gestionnaire de podcasts pour les établissements Itinova',
directory: 'itinova-podcasts',
giteaRepo: 'itinova-podcasts',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://podcasts.recette.santinova-soft.org',
prod: 'https://podcasts.santinova-soft.org',
},
containerName: 'itinova-podcasts',
healthCheckUrl: 'https://podcasts.recette.santinova-soft.org',
port: 3000,
category: 'ITINOVA',
status: 'production',
},
{
id: 'veille-reglementaire',
name: 'Veille Réglementaire',
description: 'Application de veille réglementaire et appels à projets',
directory: 'veille-reglementaire',
giteaRepo: 'veille-reglementaire',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://veille.recette.santinova-soft.org',
prod: 'https://veille.santinova-soft.org',
},
containerName: 'veille-reglementaire-recette',
healthCheckUrl: 'https://veille.recette.santinova-soft.org',
port: 3000,
category: 'ITINOVA',
status: 'production',
},
{
id: 'itinova-vehicle-exchange',
name: 'Itinova Gestion de Flotte',
description: 'Bourse de véhicules Itinova — Partage et échange de véhicules entre établissements',
directory: 'itinova-vehicle-exchange',
giteaRepo: 'itinova-vehicle-exchange',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://flotte.recette.santinova-soft.org',
prod: 'https://flotte.santinova-soft.org',
},
containerName: 'itinova-vehicle-exchange',
healthCheckUrl: 'https://flotte.recette.santinova-soft.org',
port: 3000,
category: 'ITINOVA',
status: 'production',
},
{
id: 'pilotage-masse-salariale',
name: 'Pilotage Masse Salariale',
description: 'Application de pilotage de la masse salariale Itinova',
directory: 'pilotage-masse-salariale',
giteaRepo: 'pilotage-masse-salariale',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://pilotage-ms.recette.santinova-soft.org',
prod: 'https://pilotage-ms.santinova-soft.org',
},
containerName: 'pilotage-masse-salariale-app',
healthCheckUrl: 'https://pilotage-ms.recette.santinova-soft.org',
port: 3000,
category: 'ITINOVA',
status: 'recette',
},
{
id: 'sonum',
name: 'SONUM',
description: 'Cartographie des Solutions Numériques FEHAP',
directory: 'sonum',
giteaRepo: 'sonum',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://sonum.recette.santinova-soft.org',
prod: 'https://sonum.santinova-soft.org',
},
containerName: 'sonum',
healthCheckUrl: 'https://sonum.recette.santinova-soft.org',
port: 3000,
category: 'SANTINOVA',
status: 'production',
},
{
id: 'demat-facturation-dsi',
name: 'Démat. Facturation DSI',
description: 'Dématérialisation des factures DSI — Import email/dossier, analyse IA, export SharePoint/PDF, validation BAP',
directory: 'demat-facturation-dsi',
giteaRepo: 'demat-facturation-dsi',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://demat-facturation.recette.santinova-soft.org',
prod: 'https://demat-facturation.santinova-soft.org',
},
containerName: 'demat-facturation-app',
healthCheckUrl: 'https://demat-facturation.recette.santinova-soft.org',
port: 3000,
image: 'images/demat-facturation-dsi.jpg',
category: 'SANTINOVA',
status: 'production',
},
{
id: 'facturation-santinova',
name: 'Facturation Santinova',
description: 'Application de gestion de la facturation Santinova',
directory: 'facturation-santinova',
giteaRepo: 'facturation-santinova',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://facturation.recette.santinova-soft.org',
prod: 'https://facturation.santinova-soft.org',
},
containerName: 'facturation-santinova-app',
healthCheckUrl: 'https://facturation.recette.santinova-soft.org',
port: 3001,
category: 'SANTINOVA',
status: 'recette',
},
{
id: 'formation-manager-itinova',
name: 'Formation Manager',
description: 'Gestion des Formations Itinova',
directory: 'formation-manager-itinova',
giteaRepo: 'formation-manager-itinova',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://formations.recette.santinova-soft.org',
prod: 'https://formations.itinova.org',
},
containerName: 'formation-manager-itinova',
healthCheckUrl: 'https://formations.recette.santinova-soft.org',
port: 3000,
category: 'ITINOVA',
status: 'production',
},
{
id: 'portail-santinova',
name: 'Portail Applicatif',
description: 'Portail applicatif Santinova — accès centralisé aux applications',
directory: 'portail-santinova',
giteaRepo: 'portail-santinova',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://portail.recette.santinova-soft.org',
prod: 'https://portail.santinova-soft.org',
},
containerName: 'portail-santinova',
healthCheckUrl: 'https://portail.recette.santinova-soft.org',
port: 3000,
category: 'INFRA',
status: 'production',
},
{
id: 'manus-dashboard',
name: 'Dashboard Recette',
description: 'Dashboard de gestion de l\'infrastructure Manus',
directory: 'manus-dashboard',
giteaRepo: 'manus-dashboard',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://dashboard.recette.santinova-soft.org',
prod: 'https://dashboard.santinova-soft.org',
},
containerName: 'manus-dashboard',
healthCheckUrl: 'https://dashboard.recette.santinova-soft.org',
port: 3001,
category: 'INFRA',
status: 'production',
},
{
id: 'itinova-budget-si',
name: 'Gestion Budget Informatique',
description: 'Application de gestion du budget informatique DSI Itinova',
directory: 'itinova-budget-si',
giteaRepo: 'itinova-budget-si',
giteaOwner: 'manus-admin',
urls: {
recette: 'https://budget-si.recette.santinova-soft.org',
prod: 'https://budget-si.santinova-soft.org',
},
containerName: 'itinova-budget-si-app',
healthCheckUrl: 'https://budget-si.recette.santinova-soft.org',
port: 3000,
category: 'SANTINOVA',
status: 'recette',
},
],
healthCheckInterval: Number.parseInt(process.env.HEALTH_CHECK_INTERVAL, 10) || 60000,
// Cache mémoire hydraté au démarrage puis après chaque création d'application.
apps: [],
};

View File

@@ -3,125 +3,130 @@ const https = require('https');
const config = require('./config');
const { getContainerInfo } = require('./docker');
// Store pour les statuts des apps
// État volatile : il est régénéré au démarrage par les contrôles de santé.
const appStatuses = new Map();
// Store pour les logs de déploiement
const deploymentLogs = [];
const MAX_DEPLOYMENT_LOGS = 50;
// Store pour les commits Gitea
const giteaCommits = new Map();
const MAX_DEPLOYMENT_LOGS = 50;
const MAX_CONCURRENT_CHECKS = 4;
// Agent HTTPS qui ignore les certificats auto-signés
// Les certificats internes peuvent être auto-signés. Les URLs sont déclarées
// dans les manifests app.json et ne sont jamais construites depuis une entrée utilisateur.
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
let runningHealthCheck = null;
// Au démarrage : marquer tous les déploiements "running" orphelins comme "failed"
// (ils ont été interrompus lors du dernier redémarrage du Dashboard)
function cleanupOrphanedDeployments() {
const orphans = deploymentLogs.filter((l) => l.status === 'running');
const orphans = deploymentLogs.filter((entry) => entry.status === 'running');
for (const entry of orphans) {
entry.status = 'failed';
entry.message = 'Déploiement interrompu (redémarrage du Dashboard)';
entry.endedAt = new Date().toISOString();
}
if (orphans.length > 0) {
console.log(`[healthcheck] Nettoyage de ${orphans.length} déploiement(s) orphelin(s) au démarrage`);
orphans.forEach((l) => {
l.status = 'failed';
l.message = 'Déploiement interrompu (redémarrage du Dashboard)';
l.endedAt = new Date().toISOString();
});
console.log(`[healthcheck] ${orphans.length} déploiement(s) orphelin(s) clôturé(s)`);
}
}
/**
* Vérifier la santé d'une URL
*/
/** Vérifie une URL applicative avec un délai borné. */
async function checkUrl(url) {
try {
const start = Date.now();
const startedAt = Date.now();
const response = await axios.get(url, {
timeout: 10000,
timeout: 8000,
httpsAgent,
validateStatus: (status) => status < 500,
});
const responseTime = Date.now() - start;
return {
online: true,
statusCode: response.status,
responseTime,
responseTime: Date.now() - startedAt,
};
} catch (err) {
} catch (error) {
return {
online: false,
statusCode: null,
responseTime: null,
error: err.message,
error: error.message,
};
}
}
/**
* Vérifier la santé d'une application
* Vérifie une application déployée sur l'environnement du dashboard courant.
* Le dashboard recette ne sonde plus les URLs prod, et inversement : cela évite
* de mélanger les états des environnements et divise les appels HTTP inutiles.
*/
async function checkApp(appConfig) {
const environment = config.environment;
const localUrl = appConfig.urls?.[environment];
const result = {
id: appConfig.id,
name: appConfig.name,
description: appConfig.description,
description: appConfig.description || '',
urls: appConfig.urls,
containerName: appConfig.containerName,
lastCheck: new Date().toISOString(),
status: 'unknown',
container: null,
health: {
recette: null,
prod: null,
},
health: { recette: null, prod: null },
};
// Vérifier le conteneur Docker
const containerInfo = await getContainerInfo(appConfig.containerName);
const [containerInfo, health] = await Promise.all([
getContainerInfo(appConfig.containerName),
localUrl ? checkUrl(localUrl) : Promise.resolve(null),
]);
result.container = containerInfo;
result.health[environment] = health;
// Health check recette
if (appConfig.urls.recette) {
result.health.recette = await checkUrl(appConfig.urls.recette);
}
// Health check prod
if (appConfig.urls.prod) {
result.health.prod = await checkUrl(appConfig.urls.prod);
}
// Déterminer le statut global
if (!containerInfo || !containerInfo.running) {
result.status = 'offline';
} else if (result.health.recette && result.health.recette.online) {
} else if (health?.online) {
result.status = 'online';
} else if (containerInfo.running) {
result.status = 'error';
} else {
result.status = 'offline';
result.status = 'error';
}
// Stocker le résultat
appStatuses.set(appConfig.id, result);
return result;
}
/**
* Lancer les health checks pour toutes les apps
*/
async function checkAllApps() {
const results = [];
for (const app of config.apps) {
const result = await checkApp(app);
results.push(result);
/** Exécute un lot asynchrone avec une concurrence bornée. */
async function mapWithConcurrency(items, limit, worker) {
const results = new Array(items.length);
let cursor = 0;
async function runWorker() {
while (cursor < items.length) {
const index = cursor++;
results[index] = await worker(items[index]);
}
}
const workerCount = Math.min(Math.max(limit, 1), items.length);
await Promise.all(Array.from({ length: workerCount }, runWorker));
return results;
}
/**
* Ajouter un log de déploiement (retourne l'entrée créée avec son id)
* Lance les contrôles sans chevauchement : si un cycle est encore actif, les
* consommateurs récupèrent sa même promesse au lieu de redoubler les requêtes.
*/
function checkAllApps() {
if (runningHealthCheck) return runningHealthCheck;
runningHealthCheck = mapWithConcurrency(config.apps, MAX_CONCURRENT_CHECKS, checkApp)
.catch((error) => {
console.error('[healthcheck] Échec du cycle :', error.message);
throw error;
})
.finally(() => {
runningHealthCheck = null;
});
return runningHealthCheck;
}
function addDeploymentLog(appId, log) {
const entry = {
id: Date.now().toString(),
@@ -130,45 +135,25 @@ function addDeploymentLog(appId, log) {
...log,
};
deploymentLogs.unshift(entry);
if (deploymentLogs.length > MAX_DEPLOYMENT_LOGS) {
deploymentLogs.pop();
}
if (deploymentLogs.length > MAX_DEPLOYMENT_LOGS) deploymentLogs.pop();
return entry;
}
/**
* Mettre à jour un log de déploiement existant par son id
* Permet de passer de "running" à "success" ou "failed" sans créer une nouvelle entrée
*/
function updateDeploymentLog(entryId, updates) {
const entry = deploymentLogs.find((l) => l.id === entryId);
if (entry) {
Object.assign(entry, updates, { updatedAt: new Date().toISOString() });
return entry;
}
return null;
const entry = deploymentLogs.find((log) => log.id === entryId);
if (!entry) return null;
Object.assign(entry, updates, { updatedAt: new Date().toISOString() });
return entry;
}
/**
* Récupérer les logs de déploiement
*/
function getDeploymentLogs(appId = null) {
if (appId) {
return deploymentLogs.filter((l) => l.appId === appId);
}
return deploymentLogs;
return appId ? deploymentLogs.filter((log) => log.appId === appId) : deploymentLogs;
}
/**
* Récupérer le statut d'une app
*/
function getAppStatus(appId) {
return appStatuses.get(appId) || null;
}
/**
* Récupérer tous les statuts
*/
function getAllStatuses() {
return Array.from(appStatuses.values());
}
@@ -177,6 +162,7 @@ module.exports = {
checkUrl,
checkApp,
checkAllApps,
mapWithConcurrency,
addDeploymentLog,
updateDeploymentLog,
getDeploymentLogs,

View File

@@ -33,10 +33,6 @@ app.use(cors({
origin: true,
credentials: true,
}));
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
app.use(cookieParser());
app.use(morgan('combined'));
// Route webhook montée en premier avec raw body pour la vérification HMAC
app.use('/api/webhook', express.raw({ type: 'application/json', limit: '10mb' }), (req, res, next) => {
@@ -46,6 +42,11 @@ app.use('/api/webhook', express.raw({ type: 'application/json', limit: '10mb' })
}
next();
}, webhookRoutes);
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
app.use(cookieParser());
app.use(morgan('combined'));
// API routes
app.use('/api', routes);

View File

@@ -256,7 +256,7 @@ router.get('/apps-config/stacks', authMiddleware, (req, res) => {
// Créer une nouvelle application
router.post('/apps-config/create', authMiddleware, upload.single('zipFile'), async (req, res) => {
try {
const { name, description, subdomain, stack, port, needsDb, dbType, sourceType, giteaRepoUrl } = req.body;
const { name, description, subdomain, stack, port, needsDb, dbType, category, sourceType, giteaRepoUrl } = req.body;
// Validation
if (!name || !subdomain || !stack || !sourceType) {
@@ -322,6 +322,7 @@ router.post('/apps-config/create', authMiddleware, upload.single('zipFile'), asy
port: port ? parseInt(port) : null,
needsDb: needsDb === 'true' || needsDb === true,
dbType: dbType || 'mysql',
category: category || 'SANTINOVA',
sourceType,
giteaRepoUrl,
zipFilePath: req.file ? req.file.path : null,

View File

@@ -5,9 +5,11 @@
*/
const express = require('express');
const crypto = require('crypto');
const { exec } = require('child_process');
const { execFile } = require('child_process');
const path = require('path');
const fs = require('fs');
const config = require('./config');
const { findAppDirectoryByRepo } = require('./app-registry');
const router = express.Router();
@@ -16,16 +18,6 @@ const APPS_BASE_PATH = process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps';
const DEPLOY_SCRIPT = '/opt/manus-deploy/scripts/deploy-app.sh';
const LOG_DIR = '/var/log/manus-deploy';
// Mapping dépôt Gitea -> nom du dossier application sur le serveur
const REPO_TO_APP_MAP = {
'itinova-contacts': 'itinova-contacts',
'itinova-podcasts': 'itinova-podcasts',
'veille-reglementaire': 'veille-reglementaire',
'itinova-vehicle-exchange': 'itinova-vehicle-exchange',
'manus-dashboard': 'manus-dashboard',
'itinova-budget-si': 'itinova-budget-si',
};
// Déploiements en cours (évite les doubles déclenchements)
const deployingApps = new Set();
@@ -35,9 +27,6 @@ function verifyGiteaSignature(req) {
return true;
}
const signature = req.headers['x-gitea-signature'] || req.headers['x-hub-signature-256'] || '';
console.log('[Webhook DEBUG] All headers:', JSON.stringify(Object.keys(req.headers)));
console.log('[Webhook DEBUG] x-gitea-signature:', req.headers['x-gitea-signature'] || 'ABSENT');
console.log('[Webhook DEBUG] rawBody available:', !!req.rawBody, 'length:', req.rawBody ? req.rawBody.length : 0);
// Utiliser rawBody si disponible (préservé avant express.json), sinon re-sérialiser
const bodyStr = req.rawBody ? req.rawBody.toString() : JSON.stringify(req.body);
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET).update(bodyStr).digest('hex');
@@ -63,10 +52,14 @@ function runDeploy(appName, branch, commitHash, committer, broadcast) {
}
const env = { ...process.env, APPS_BASE_PATH };
const cmd = `bash ${DEPLOY_SCRIPT} ${appName} recette`;
let output = '';
const child = exec(cmd, { env, timeout: 600000 });
// Les deux serveurs partagent le module, mais leurs scripts de déploiement
// nattendent 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}`); });
@@ -105,8 +98,9 @@ router.post('/gitea', (req, res) => {
console.log(`[Webhook] Push: repo=${repoName}, branch=${branch}, commit=${commitHash}, by=${committer}`);
const appName = REPO_TO_APP_MAP[repoName];
if (!appName) return res.status(200).json({ message: `Dépôt ${repoName} non configuré` });
// Le manifeste app.json associe le dépôt au dossier déployé : pas de mapping statique à maintenir.
const appName = findAppDirectoryByRepo(config, repoName);
if (!appName) 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: `Déploiement de ${appName} déclenché`, commit: commitHash, branch, committer });

View File

@@ -0,0 +1,61 @@
/*
* Tests de non-régression du registre d'applications.
* Le registre doit faire de app.json l'unique source de vérité des applications déployées.
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { discoverApps, refreshApps } = require('../src/app-registry');
function writeManifest(basePath, directory, manifest) {
const appPath = path.join(basePath, directory);
fs.mkdirSync(appPath, { recursive: true });
fs.writeFileSync(path.join(appPath, 'app.json'), JSON.stringify(manifest), 'utf8');
}
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'app-registry-'));
try {
writeManifest(sandbox, 'contacts', {
id: 'itinova-contacts',
name: 'Itinova Contacts',
category: 'ITINOVA',
containerName: 'itinova-contacts',
image: 'images/itinova-contacts.png',
urls: {
recette: 'https://contacts.recette.santinova-soft.org',
prod: 'https://contacts.santinova-soft.org',
},
});
writeManifest(sandbox, 'invalide', {
id: 'app-invalide',
name: 'Application invalide',
category: 'INCONNUE',
urls: { recette: 'https://invalide.recette.santinova-soft.org' },
});
const discovery = discoverApps({ appsBasePath: sandbox, environment: 'recette' });
assert.equal(discovery.apps.length, 1, 'un manifeste invalide doit être exclu');
assert.equal(discovery.errors.length, 1, 'une erreur explicite doit être retournée');
const app = discovery.apps[0];
assert.equal(app.directory, 'contacts', 'le dossier doit être déduit du chemin du manifeste');
assert.equal(app.healthCheckUrl, app.urls.recette, 'la vérification doit cibler lenvironnement courant');
assert.equal(app.containerName, 'itinova-contacts');
const config = {
appsBasePath: sandbox,
environment: 'recette',
apps: [{ id: 'itinova-contacts', name: 'ancienne valeur statique' }],
};
const refresh = refreshApps(config);
assert.equal(refresh.apps.length, 1, 'les applications non déployées ne doivent pas rester en cache');
assert.equal(config.apps[0].name, 'Itinova Contacts', 'app.json doit prévaloir sur toute définition statique');
console.log('OK app-registry');
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
}

View File

@@ -0,0 +1,25 @@
/*
* Le dashboard ne doit pas lancer un nombre illimité de contrôles HTTP en même
* temps, même lorsquun grand nombre dapplications est déclaré.
*/
const assert = require('node:assert/strict');
const { mapWithConcurrency } = require('../src/healthcheck');
(async () => {
let active = 0;
let peak = 0;
const values = await mapWithConcurrency([1, 2, 3, 4, 5, 6, 7], 3, async (value) => {
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
return value * 2;
});
assert.deepEqual(values, [2, 4, 6, 8, 10, 12, 14]);
assert.ok(peak <= 3, `concurrence observée ${peak}, maximum autorisé 3`);
console.log('OK healthcheck');
})().catch((error) => {
console.error(error);
process.exit(1);
});