refactor: fiabiliser le dashboard de production
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
144
src/backend/src/app-registry.js
Normal file
144
src/backend/src/app-registry.js
Normal 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,
|
||||
};
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
// n’attendent pas le même second argument : recette attend son environnement,
|
||||
// production attend la branche Git. Cette sélection évite tout mélange.
|
||||
const deployTarget = config.environment === 'prod' ? 'main' : 'recette';
|
||||
// execFile évite toute interprétation shell du nom de dossier issu du manifeste.
|
||||
const child = execFile('bash', [DEPLOY_SCRIPT, appName, deployTarget], { env, timeout: 600000 });
|
||||
child.stdout.on('data', (d) => { output += d; process.stdout.write(`[${appName}] ${d}`); });
|
||||
child.stderr.on('data', (d) => { output += d; process.stderr.write(`[${appName}] ERR: ${d}`); });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
61
src/backend/test/app-registry.test.js
Normal file
61
src/backend/test/app-registry.test.js
Normal 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 l’environnement 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 });
|
||||
}
|
||||
25
src/backend/test/healthcheck.test.js
Normal file
25
src/backend/test/healthcheck.test.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Le dashboard ne doit pas lancer un nombre illimité de contrôles HTTP en même
|
||||
* temps, même lorsqu’un grand nombre d’applications 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);
|
||||
});
|
||||
Reference in New Issue
Block a user