refactor: fiabiliser le dashboard de production
This commit is contained in:
45
APP_MANIFEST.md
Normal file
45
APP_MANIFEST.md
Normal 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 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
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 = {
|
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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
// 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.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 });
|
||||||
|
|||||||
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