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

45
APP_MANIFEST.md Normal file
View File

@@ -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/<dossier-technique>/app.json
```
> Une application sans manifeste valide nest pas considérée comme déployée. Elle napparaî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 lapplication | 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 à linventaire. |
| `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 à lapplication. Lorsquune application est promue, son manifeste de production doit contenir lURL de production avant le démarrage du dashboard.
Le dashboard ne maintient plus de liste statique dapplications, et le portail ne maintient plus de tuiles codées en dur. Cette règle élimine les écarts de version, dURL, de catégorie et de statut entre les deux interfaces.

View File

@@ -4,33 +4,38 @@ services:
context: ./src context: ./src
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: manus-dashboard container_name: manus-dashboard
# Redémarrage automatique après un arrêt anormal ; le processus reste borné.
restart: always restart: always
mem_limit: 512m mem_limit: 512m
privileged: true privileged: true
env_file: env_file:
- .env - .env
environment: environment:
- NODE_ENV=production - NODE_ENV=${NODE_ENV:-production}
- PORT=3001 - DASHBOARD_ENV=prod
- JWT_SECRET=${DASHBOARD_JWT_SECRET} - PORT=${PORT:-3001}
- ADMIN_USERNAME=${DASHBOARD_ADMIN_USERNAME} - JWT_SECRET=${JWT_SECRET}
- ADMIN_PASSWORD=${DASHBOARD_ADMIN_PASSWORD} - ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- GITEA_URL=${GITEA_URL} - GITEA_URL=${GITEA_URL}
- GITEA_USERNAME=${GITEA_USERNAME} - GITEA_USERNAME=${GITEA_USERNAME}
- GITEA_PASSWORD=${GITEA_PASSWORD} - GITEA_PASSWORD=${GITEA_PASSWORD}
- WEBHOOK_SECRET=${WEBHOOK_SECRET} - GITEA_TOKEN=${GITEA_TOKEN}
- APPS_BASE_PATH=${APPS_BASE_PATH} - GITEA_RECETTE_URL=${GITEA_RECETTE_URL}
- INFRA_BASE_PATH=${INFRA_BASE_PATH} - GITEA_RECETTE_TOKEN=${GITEA_RECETTE_TOKEN}
- HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL} - 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: volumes:
- /opt/manus-deploy/webhook-patched.js:/app/backend/src/webhook.js:ro
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- /opt/manus-deploy:/opt/manus-deploy - /opt/manus-deploy:/opt/manus-deploy
networks: networks:
- web - web
labels: labels:
- "traefik.enable=true" - "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.entrypoints=websecure"
- "traefik.http.routers.manus-dashboard.tls=true" - "traefik.http.routers.manus-dashboard.tls=true"
- "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt" - "traefik.http.routers.manus-dashboard.tls.certresolver=letsencrypt"
@@ -38,6 +43,7 @@ services:
- "traefik.http.routers.manus-dashboard.priority=100" - "traefik.http.routers.manus-dashboard.priority=100"
- "traefik.http.services.manus-dashboard-svc.loadbalancer.server.port=3001" - "traefik.http.services.manus-dashboard-svc.loadbalancer.server.port=3001"
- "traefik.docker.network=web" - "traefik.docker.network=web"
networks: networks:
web: web:
external: true external: true

View File

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

View File

@@ -4,6 +4,7 @@ const path = require('path');
const { exec, execSync } = require('child_process'); const { exec, execSync } = require('child_process');
const config = require('./config'); const config = require('./config');
const { createRepo, giteaClient } = require('./gitea'); const { createRepo, giteaClient } = require('./gitea');
const { refreshApps, writeAppManifest } = require('./app-registry');
// ============ TEMPLATES DOCKERFILE ============ // ============ TEMPLATES DOCKERFILE ============
@@ -389,85 +390,29 @@ function getContainerPort(stack, customPort) {
return defaults[stack] || 3000; return defaults[stack] || 3000;
} }
// ============ APPS PERSISTENCE ============ // ============ REGISTRE DES APPLICATIONS ============
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);
}
}
/**
* 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() { function initDynamicApps() {
// 1. Charger les apps depuis .dashboard-apps.json (legacy) return refreshApps(config);
const dynamicApps = loadDynamicApps(); }
for (const app of dynamicApps) {
const existingInConfig = config.apps.findIndex((a) => a.id === app.id); /**
if (existingInConfig < 0) { * Publie le manifeste d'une application créée depuis le dashboard, puis
config.apps.push(app); * rafraîchit immédiatement le registre partagé par le dashboard et le portail.
} */
} function addDynamicApp(appDef) {
// 2. Scanner les dossiers app.json (découverte automatique) const app = writeAppManifest({
try { appsBasePath: config.appsBasePath,
const appsBasePath = config.appsBasePath || process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps'; environment: config.environment,
const entries = fs.readdirSync(appsBasePath, { withFileTypes: true }); app: appDef,
let scanned = 0; });
for (const entry of entries) { refreshApps(config);
if (!entry.isDirectory()) continue; return app;
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}`);
} }
// ============ CREATION D'APPLICATION ============ // ============ CREATION D'APPLICATION ============
@@ -481,6 +426,7 @@ async function createApplication(params, logCallback) {
port, port,
needsDb, needsDb,
dbType, dbType,
category,
sourceType, // 'zip' or 'gitea' sourceType, // 'zip' or 'gitea'
giteaRepoUrl, // URL du repo Gitea existant giteaRepoUrl, // URL du repo Gitea existant
zipFilePath, // Chemin du fichier ZIP uploadé zipFilePath, // Chemin du fichier ZIP uploadé
@@ -678,6 +624,8 @@ async function createApplication(params, logCallback) {
stack: stack, stack: stack,
needsDb: needsDb || false, needsDb: needsDb || false,
dbType: dbType || null, dbType: dbType || null,
category: category || 'SANTINOVA',
image: `images/${appId}.png`,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
@@ -730,7 +678,6 @@ module.exports = {
generateDockerCompose, generateDockerCompose,
getAvailableStacks, getAvailableStacks,
initDynamicApps, initDynamicApps,
loadDynamicApps,
addDynamicApp, addDynamicApp,
DOCKERFILE_TEMPLATES, 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 = { module.exports = {
port: process.env.PORT || 3001, port: Number.parseInt(process.env.PORT, 10) || 3001,
jwtSecret: process.env.JWT_SECRET || 'manus-dashboard-secret-2026', jwtSecret: process.env.JWT_SECRET || 'manus-dashboard-secret-2026',
jwtExpiry: '24h', jwtExpiry: '24h',
// Authentification environment,
auth: { auth: {
username: process.env.ADMIN_USERNAME || 'adminItinova', username: process.env.ADMIN_USERNAME || 'adminItinova',
password: process.env.ADMIN_PASSWORD || 'Itinova69!', password: process.env.ADMIN_PASSWORD || 'Itinova69!',
}, },
// Gitea
gitea: { gitea: {
url: process.env.GITEA_URL || 'http://gitea:3000', url: process.env.GITEA_URL || 'http://gitea:3000',
username: process.env.GITEA_USERNAME || 'manus-admin', username: process.env.GITEA_USERNAME || 'manus-admin',
password: process.env.GITEA_PASSWORD || 'Itinova69!', password: process.env.GITEA_PASSWORD || 'Itinova69!',
token: process.env.GITEA_TOKEN || null, token: process.env.GITEA_TOKEN || null,
}, },
// Applications config
appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps', appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps',
infrastructurePath: process.env.INFRA_BASE_PATH || '/opt/manus-deploy/infrastructure', infrastructurePath: process.env.INFRA_BASE_PATH || '/opt/manus-deploy/infrastructure',
// Health check interval (ms) healthCheckInterval: Number.parseInt(process.env.HEALTH_CHECK_INTERVAL, 10) || 60000,
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 30000,
// Apps configuration // Cache mémoire hydraté au démarrage puis après chaque création d'application.
apps: [ 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',
},
],
}; };

View File

@@ -3,125 +3,130 @@ const https = require('https');
const config = require('./config'); const config = require('./config');
const { getContainerInfo } = require('./docker'); 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(); const appStatuses = new Map();
// Store pour les logs de déploiement
const deploymentLogs = []; const deploymentLogs = [];
const MAX_DEPLOYMENT_LOGS = 50;
// Store pour les commits Gitea
const giteaCommits = new Map(); 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 }); 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() { 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) { if (orphans.length > 0) {
console.log(`[healthcheck] Nettoyage de ${orphans.length} déploiement(s) orphelin(s) au démarrage`); console.log(`[healthcheck] ${orphans.length} déploiement(s) orphelin(s) clôturé(s)`);
orphans.forEach((l) => {
l.status = 'failed';
l.message = 'Déploiement interrompu (redémarrage du Dashboard)';
l.endedAt = new Date().toISOString();
});
} }
} }
/** /** Vérifie une URL applicative avec un délai borné. */
* Vérifier la santé d'une URL
*/
async function checkUrl(url) { async function checkUrl(url) {
try { try {
const start = Date.now(); const startedAt = Date.now();
const response = await axios.get(url, { const response = await axios.get(url, {
timeout: 10000, timeout: 8000,
httpsAgent, httpsAgent,
validateStatus: (status) => status < 500, validateStatus: (status) => status < 500,
}); });
const responseTime = Date.now() - start;
return { return {
online: true, online: true,
statusCode: response.status, statusCode: response.status,
responseTime, responseTime: Date.now() - startedAt,
}; };
} catch (err) { } catch (error) {
return { return {
online: false, online: false,
statusCode: null, statusCode: null,
responseTime: 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) { async function checkApp(appConfig) {
const environment = config.environment;
const localUrl = appConfig.urls?.[environment];
const result = { const result = {
id: appConfig.id, id: appConfig.id,
name: appConfig.name, name: appConfig.name,
description: appConfig.description, description: appConfig.description || '',
urls: appConfig.urls, urls: appConfig.urls,
containerName: appConfig.containerName, containerName: appConfig.containerName,
lastCheck: new Date().toISOString(), lastCheck: new Date().toISOString(),
status: 'unknown', status: 'unknown',
container: null, container: null,
health: { health: { recette: null, prod: null },
recette: null,
prod: null,
},
}; };
// Vérifier le conteneur Docker const [containerInfo, health] = await Promise.all([
const containerInfo = await getContainerInfo(appConfig.containerName); getContainerInfo(appConfig.containerName),
localUrl ? checkUrl(localUrl) : Promise.resolve(null),
]);
result.container = containerInfo; 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) { if (!containerInfo || !containerInfo.running) {
result.status = 'offline'; result.status = 'offline';
} else if (result.health.recette && result.health.recette.online) { } else if (health?.online) {
result.status = 'online'; result.status = 'online';
} else if (containerInfo.running) {
result.status = 'error';
} else { } else {
result.status = 'offline'; result.status = 'error';
} }
// Stocker le résultat
appStatuses.set(appConfig.id, result); appStatuses.set(appConfig.id, result);
return result; return result;
} }
/** /** Exécute un lot asynchrone avec une concurrence bornée. */
* Lancer les health checks pour toutes les apps async function mapWithConcurrency(items, limit, worker) {
*/ const results = new Array(items.length);
async function checkAllApps() { let cursor = 0;
const results = [];
for (const app of config.apps) { async function runWorker() {
const result = await checkApp(app); while (cursor < items.length) {
results.push(result); 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; 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) { function addDeploymentLog(appId, log) {
const entry = { const entry = {
id: Date.now().toString(), id: Date.now().toString(),
@@ -130,45 +135,25 @@ function addDeploymentLog(appId, log) {
...log, ...log,
}; };
deploymentLogs.unshift(entry); deploymentLogs.unshift(entry);
if (deploymentLogs.length > MAX_DEPLOYMENT_LOGS) { if (deploymentLogs.length > MAX_DEPLOYMENT_LOGS) deploymentLogs.pop();
deploymentLogs.pop();
}
return entry; 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) { function updateDeploymentLog(entryId, updates) {
const entry = deploymentLogs.find((l) => l.id === entryId); const entry = deploymentLogs.find((log) => log.id === entryId);
if (entry) { if (!entry) return null;
Object.assign(entry, updates, { updatedAt: new Date().toISOString() }); Object.assign(entry, updates, { updatedAt: new Date().toISOString() });
return entry; return entry;
}
return null;
} }
/**
* Récupérer les logs de déploiement
*/
function getDeploymentLogs(appId = null) { function getDeploymentLogs(appId = null) {
if (appId) { return appId ? deploymentLogs.filter((log) => log.appId === appId) : deploymentLogs;
return deploymentLogs.filter((l) => l.appId === appId);
}
return deploymentLogs;
} }
/**
* Récupérer le statut d'une app
*/
function getAppStatus(appId) { function getAppStatus(appId) {
return appStatuses.get(appId) || null; return appStatuses.get(appId) || null;
} }
/**
* Récupérer tous les statuts
*/
function getAllStatuses() { function getAllStatuses() {
return Array.from(appStatuses.values()); return Array.from(appStatuses.values());
} }
@@ -177,6 +162,7 @@ module.exports = {
checkUrl, checkUrl,
checkApp, checkApp,
checkAllApps, checkAllApps,
mapWithConcurrency,
addDeploymentLog, addDeploymentLog,
updateDeploymentLog, updateDeploymentLog,
getDeploymentLogs, getDeploymentLogs,

View File

@@ -33,10 +33,6 @@ app.use(cors({
origin: true, origin: true,
credentials: 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 // 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) => { 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(); next();
}, webhookRoutes); }, webhookRoutes);
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
app.use(cookieParser());
app.use(morgan('combined'));
// API routes // API routes
app.use('/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 // Créer une nouvelle application
router.post('/apps-config/create', authMiddleware, upload.single('zipFile'), async (req, res) => { router.post('/apps-config/create', authMiddleware, upload.single('zipFile'), async (req, res) => {
try { 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 // Validation
if (!name || !subdomain || !stack || !sourceType) { if (!name || !subdomain || !stack || !sourceType) {
@@ -322,6 +322,7 @@ router.post('/apps-config/create', authMiddleware, upload.single('zipFile'), asy
port: port ? parseInt(port) : null, port: port ? parseInt(port) : null,
needsDb: needsDb === 'true' || needsDb === true, needsDb: needsDb === 'true' || needsDb === true,
dbType: dbType || 'mysql', dbType: dbType || 'mysql',
category: category || 'SANTINOVA',
sourceType, sourceType,
giteaRepoUrl, giteaRepoUrl,
zipFilePath: req.file ? req.file.path : null, zipFilePath: req.file ? req.file.path : null,

View File

@@ -5,9 +5,11 @@
*/ */
const express = require('express'); const express = require('express');
const crypto = require('crypto'); const crypto = require('crypto');
const { exec } = require('child_process'); const { execFile } = require('child_process');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const config = require('./config');
const { findAppDirectoryByRepo } = require('./app-registry');
const router = express.Router(); 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 DEPLOY_SCRIPT = '/opt/manus-deploy/scripts/deploy-app.sh';
const LOG_DIR = '/var/log/manus-deploy'; 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) // Déploiements en cours (évite les doubles déclenchements)
const deployingApps = new Set(); const deployingApps = new Set();
@@ -35,9 +27,6 @@ function verifyGiteaSignature(req) {
return true; return true;
} }
const signature = req.headers['x-gitea-signature'] || req.headers['x-hub-signature-256'] || ''; 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 // 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 bodyStr = req.rawBody ? req.rawBody.toString() : JSON.stringify(req.body);
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET).update(bodyStr).digest('hex'); 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 env = { ...process.env, APPS_BASE_PATH };
const cmd = `bash ${DEPLOY_SCRIPT} ${appName} recette`;
let output = ''; 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.stdout.on('data', (d) => { output += d; process.stdout.write(`[${appName}] ${d}`); });
child.stderr.on('data', (d) => { output += d; process.stderr.write(`[${appName}] ERR: ${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}`); console.log(`[Webhook] Push: repo=${repoName}, branch=${branch}, commit=${commitHash}, by=${committer}`);
const appName = REPO_TO_APP_MAP[repoName]; // Le manifeste app.json associe le dépôt au dossier déployé : pas de mapping statique à maintenir.
if (!appName) return res.status(200).json({ message: `Dépôt ${repoName} non configuré` }); 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` }); 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 }); 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);
});