From e07e56955996cb4f307a9a12c19d162d9622c4d1 Mon Sep 17 00:00:00 2001 From: Manus Date: Tue, 18 Aug 2026 07:38:46 +0000 Subject: [PATCH] refactor: fiabiliser le dashboard de production --- APP_MANIFEST.md | 45 +++++ docker-compose.yml | 28 +-- src/backend/package.json | 3 +- src/backend/src/app-creator.js | 103 +++-------- src/backend/src/app-registry.js | 144 ++++++++++++++++ src/backend/src/config.js | 236 +++----------------------- src/backend/src/healthcheck.js | 156 ++++++++--------- src/backend/src/index.js | 9 +- src/backend/src/routes.js | 3 +- src/backend/src/webhook.js | 30 ++-- src/backend/test/app-registry.test.js | 61 +++++++ src/backend/test/healthcheck.test.js | 25 +++ 12 files changed, 431 insertions(+), 412 deletions(-) create mode 100644 APP_MANIFEST.md create mode 100644 src/backend/src/app-registry.js create mode 100644 src/backend/test/app-registry.test.js create mode 100644 src/backend/test/healthcheck.test.js diff --git a/APP_MANIFEST.md b/APP_MANIFEST.md new file mode 100644 index 0000000..ce78df2 --- /dev/null +++ b/APP_MANIFEST.md @@ -0,0 +1,45 @@ +# Contrat `app.json` + +Le dashboard et le portail applicatif utilisent exclusivement le manifeste placé dans le dossier de chaque application déployée : + +```text +/opt/manus-deploy/apps//app.json +``` + +> Une application sans manifeste valide n’est pas considérée comme déployée. Elle n’apparaît donc ni dans le dashboard ni dans le portail. + +## Schéma minimal + +```json +{ + "id": "itinova-contacts", + "name": "Itinova Contacts", + "category": "ITINOVA", + "containerName": "itinova-contacts", + "giteaRepo": "itinova-contacts", + "giteaOwner": "manus-admin", + "image": "images/itinova-contacts.png", + "urls": { + "recette": "https://contacts.recette.santinova-soft.org", + "prod": "https://contacts.santinova-soft.org" + } +} +``` + +| Champ | Rôle | Règle | +|---|---|---| +| `id` | Identifiant stable de l’application | Minuscules, chiffres et tirets uniquement. | +| `name` | Libellé affiché | Chaîne non vide. | +| `category` | Emplacement dans le portail | `ITINOVA`, `SANTINOVA` ou `INFRA`. Les applications `INFRA` restent visibles dans le dashboard mais sont exclues du portail. | +| `containerName` | Conteneur géré par Docker | Obligatoire pour le contrôle de démarrage et de statut. À défaut, le dashboard utilise `id`. | +| `giteaRepo` | Dépôt source | Sert au webhook CI/CD et à l’inventaire. | +| `giteaOwner` | Organisation/propriétaire Gitea | Facultatif si la valeur par défaut suffit. | +| `image` | Image locale du portail | Chemin relatif à `portail-santinova/images/`. | +| `urls.recette` | URL de recette | Obligatoire et HTTPS pour un dashboard recette. | +| `urls.prod` | URL de production | Obligatoire et HTTPS pour un dashboard de production. | + +## Règles de déploiement + +Chaque premier déploiement doit créer ou mettre à jour le manifeste dans le même commit que le `docker-compose.yml` et les éléments nécessaires à l’application. Lorsqu’une application est promue, son manifeste de production doit contenir l’URL de production avant le démarrage du dashboard. + +Le dashboard ne maintient plus de liste statique d’applications, et le portail ne maintient plus de tuiles codées en dur. Cette règle élimine les écarts de version, d’URL, de catégorie et de statut entre les deux interfaces. diff --git a/docker-compose.yml b/docker-compose.yml index 1e09e63..c0ac968 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,33 +4,38 @@ services: context: ./src dockerfile: Dockerfile container_name: manus-dashboard + # Redémarrage automatique après un arrêt anormal ; le processus reste borné. restart: always mem_limit: 512m privileged: true env_file: - .env environment: - - NODE_ENV=production - - PORT=3001 - - JWT_SECRET=${DASHBOARD_JWT_SECRET} - - ADMIN_USERNAME=${DASHBOARD_ADMIN_USERNAME} - - ADMIN_PASSWORD=${DASHBOARD_ADMIN_PASSWORD} + - NODE_ENV=${NODE_ENV:-production} + - DASHBOARD_ENV=prod + - PORT=${PORT:-3001} + - JWT_SECRET=${JWT_SECRET} + - ADMIN_USERNAME=${ADMIN_USERNAME} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} - GITEA_URL=${GITEA_URL} - GITEA_USERNAME=${GITEA_USERNAME} - GITEA_PASSWORD=${GITEA_PASSWORD} - - WEBHOOK_SECRET=${WEBHOOK_SECRET} - - APPS_BASE_PATH=${APPS_BASE_PATH} - - INFRA_BASE_PATH=${INFRA_BASE_PATH} - - HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL} + - GITEA_TOKEN=${GITEA_TOKEN} + - GITEA_RECETTE_URL=${GITEA_RECETTE_URL} + - GITEA_RECETTE_TOKEN=${GITEA_RECETTE_TOKEN} + - GITEA_PASSWORD_PROD=${GITEA_PASSWORD} + - APPS_BASE_PATH=${APPS_BASE_PATH:-/opt/manus-deploy/apps} + - INFRA_BASE_PATH=${INFRA_BASE_PATH:-/opt/manus-deploy/infrastructure} + # Contrôles bornés et dédoublonnés par healthcheck.js. + - HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL:-60000} volumes: - - /opt/manus-deploy/webhook-patched.js:/app/backend/src/webhook.js:ro - /var/run/docker.sock:/var/run/docker.sock - /opt/manus-deploy:/opt/manus-deploy networks: - web labels: - "traefik.enable=true" - - "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.recette.santinova-soft.org`) || Host(`dashboard.santinova-soft.org`)" + - "traefik.http.routers.manus-dashboard.rule=Host(`dashboard.santinova-soft.org`)" - "traefik.http.routers.manus-dashboard.entrypoints=websecure" - "traefik.http.routers.manus-dashboard.tls=true" - "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt" @@ -38,6 +43,7 @@ services: - "traefik.http.routers.manus-dashboard.priority=100" - "traefik.http.services.manus-dashboard-svc.loadbalancer.server.port=3001" - "traefik.docker.network=web" + networks: web: external: true diff --git a/src/backend/package.json b/src/backend/package.json index 3dce267..1295450 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -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", diff --git a/src/backend/src/app-creator.js b/src/backend/src/app-creator.js index 0ec2901..c388d66 100644 --- a/src/backend/src/app-creator.js +++ b/src/backend/src/app-creator.js @@ -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, }; diff --git a/src/backend/src/app-registry.js b/src/backend/src/app-registry.js new file mode 100644 index 0000000..cbb9027 --- /dev/null +++ b/src/backend/src/app-registry.js @@ -0,0 +1,144 @@ +/* + * Registre des applications déployées. + * + * Source de vérité : /opt/manus-deploy/apps//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, +}; diff --git a/src/backend/src/config.js b/src/backend/src/config.js index e6540a1..747d129 100644 --- a/src/backend/src/config.js +++ b/src/backend/src/config.js @@ -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.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: [], }; diff --git a/src/backend/src/healthcheck.js b/src/backend/src/healthcheck.js index 45fceaa..6929f4a 100644 --- a/src/backend/src/healthcheck.js +++ b/src/backend/src/healthcheck.js @@ -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, diff --git a/src/backend/src/index.js b/src/backend/src/index.js index 07b03f6..b8c951e 100644 --- a/src/backend/src/index.js +++ b/src/backend/src/index.js @@ -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); diff --git a/src/backend/src/routes.js b/src/backend/src/routes.js index fbdd2e4..3193824 100644 --- a/src/backend/src/routes.js +++ b/src/backend/src/routes.js @@ -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, diff --git a/src/backend/src/webhook.js b/src/backend/src/webhook.js index 69ff5fd..c439393 100644 --- a/src/backend/src/webhook.js +++ b/src/backend/src/webhook.js @@ -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 }); diff --git a/src/backend/test/app-registry.test.js b/src/backend/test/app-registry.test.js new file mode 100644 index 0000000..7a5c46a --- /dev/null +++ b/src/backend/test/app-registry.test.js @@ -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 }); +} diff --git a/src/backend/test/healthcheck.test.js b/src/backend/test/healthcheck.test.js new file mode 100644 index 0000000..1e09f94 --- /dev/null +++ b/src/backend/test/healthcheck.test.js @@ -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); +});