feat: découverte dynamique apps - portail dynamique - fix URLs
This commit is contained in:
710
backend/src/app-creator.js
Normal file
710
backend/src/app-creator.js
Normal file
@@ -0,0 +1,710 @@
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
const { exec, execSync } = require('child_process');
|
||||
const config = require('./config');
|
||||
const { createRepo, giteaClient } = require('./gitea');
|
||||
|
||||
// ============ TEMPLATES DOCKERFILE ============
|
||||
|
||||
const DOCKERFILE_TEMPLATES = {
|
||||
nodejs: (port) => `# ── Stage 1: Build ──────────────────────────────────────────────
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy lockfiles for better layer caching
|
||||
COPY package.json pnpm-lock.yaml* yarn.lock* ./
|
||||
|
||||
# Copy patches directory if it exists (needed by pnpm before install)
|
||||
COPY patches/ ./patches/
|
||||
|
||||
# Detect package manager and install ALL dependencies (including devDependencies for build)
|
||||
RUN if [ -f pnpm-lock.yaml ]; then \\
|
||||
corepack enable && corepack prepare pnpm@latest --activate && \\
|
||||
pnpm install --no-frozen-lockfile; \\
|
||||
elif [ -f yarn.lock ]; then \\
|
||||
yarn install; \\
|
||||
else \\
|
||||
npm install --legacy-peer-deps; \\
|
||||
fi
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application (frontend + backend if applicable)
|
||||
RUN if [ -f package.json ] && grep -q '"build"' package.json; then \\
|
||||
if [ -f pnpm-lock.yaml ]; then \\
|
||||
pnpm run build; \\
|
||||
elif [ -f yarn.lock ]; then \\
|
||||
yarn build; \\
|
||||
else \\
|
||||
npm run build; \\
|
||||
fi; \\
|
||||
fi
|
||||
|
||||
# ── Stage 2: Production ─────────────────────────────────────────
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml* yarn.lock* ./
|
||||
|
||||
# Copy patches directory if it exists (needed by pnpm for install)
|
||||
COPY patches/ ./patches/
|
||||
|
||||
# Install ALL dependencies (some projects import dev deps at runtime like vite)
|
||||
RUN if [ -f pnpm-lock.yaml ]; then \\
|
||||
corepack enable && corepack prepare pnpm@latest --activate && \\
|
||||
pnpm install --no-frozen-lockfile; \\
|
||||
elif [ -f yarn.lock ]; then \\
|
||||
yarn install; \\
|
||||
else \\
|
||||
npm install --legacy-peer-deps; \\
|
||||
fi
|
||||
|
||||
# Copy built artifacts from builder
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Copy drizzle migrations if they exist
|
||||
COPY drizzle/ ./drizzle/
|
||||
COPY drizzle.config.ts* ./
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=\${port}
|
||||
EXPOSE \${port}
|
||||
|
||||
# Start the application
|
||||
CMD if grep -q '"start"' package.json; then \\
|
||||
npm start; \\
|
||||
elif [ -f dist/index.js ]; then \\
|
||||
node dist/index.js; \\
|
||||
else \\
|
||||
node index.js; \\
|
||||
fi
|
||||
`,
|
||||
|
||||
'nodejs-next': (port) => `# ── Stage 1: Build ──────────────────────────────────────────────
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml* yarn.lock* ./
|
||||
RUN if [ -f pnpm-lock.yaml ]; then \\
|
||||
corepack enable && corepack prepare pnpm@latest --activate && \\
|
||||
pnpm install --no-frozen-lockfile; \\
|
||||
elif [ -f yarn.lock ]; then \\
|
||||
yarn install; \\
|
||||
else \\
|
||||
npm install --legacy-peer-deps; \\
|
||||
fi
|
||||
COPY . .
|
||||
RUN if [ -f pnpm-lock.yaml ]; then pnpm run build; \\
|
||||
elif [ -f yarn.lock ]; then yarn build; \\
|
||||
else npm run build; fi
|
||||
|
||||
# ── Stage 2: Production ─────────────────────────────────────────
|
||||
FROM node:22-slim
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE \${port}
|
||||
|
||||
CMD ["npm", "start"]
|
||||
`,
|
||||
|
||||
python: (port) => `FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE \${port}
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
`,
|
||||
|
||||
'python-django': (port) => `FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN python manage.py collectstatic --noinput
|
||||
|
||||
EXPOSE \${port}
|
||||
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:\${port}", "config.wsgi:application"]
|
||||
`,
|
||||
|
||||
'python-flask': (port) => `FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install gunicorn
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE \${port}
|
||||
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:\${port}", "app:app"]
|
||||
`,
|
||||
|
||||
php: (port) => `FROM php:8.3-apache
|
||||
|
||||
RUN a2enmod rewrite
|
||||
RUN docker-php-ext-install pdo pdo_mysql mysqli
|
||||
|
||||
COPY . /var/www/html/
|
||||
|
||||
RUN chown -R www-data:www-data /var/www/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["apache2-foreground"]
|
||||
`,
|
||||
|
||||
'php-laravel': (port) => `FROM php:8.3-fpm
|
||||
|
||||
RUN apt-get update && apt-get install -y \\
|
||||
git curl zip unzip libpng-dev libonig-dev libxml2-dev \\
|
||||
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
|
||||
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /var/www
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN composer install --no-dev --optimize-autoloader
|
||||
RUN php artisan config:cache
|
||||
|
||||
EXPOSE \${port}
|
||||
|
||||
CMD ["php-fpm"]
|
||||
`,
|
||||
|
||||
static: () => `FROM nginx:alpine
|
||||
|
||||
COPY . /usr/share/nginx/html/
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
`,
|
||||
|
||||
'static-react': () => `FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml* yarn.lock* ./
|
||||
RUN if [ -f pnpm-lock.yaml ]; then \\
|
||||
corepack enable && corepack prepare pnpm@latest --activate && \\
|
||||
pnpm install --no-frozen-lockfile; \\
|
||||
elif [ -f yarn.lock ]; then \\
|
||||
yarn install; \\
|
||||
else \\
|
||||
npm install --legacy-peer-deps; \\
|
||||
fi
|
||||
COPY . .
|
||||
RUN if [ -f pnpm-lock.yaml ]; then pnpm run build; \\
|
||||
elif [ -f yarn.lock ]; then yarn build; \\
|
||||
else npm run build; fi
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
`,
|
||||
};
|
||||
|
||||
// ============ TEMPLATES DOCKER-COMPOSE ============
|
||||
|
||||
function generateDockerCompose(appConfig) {
|
||||
const { appId, subdomain, stack, port, needsDb, dbType } = appConfig;
|
||||
const domain = `${subdomain}.recette.santinova-soft.org`;
|
||||
const containerPort = getContainerPort(stack, port);
|
||||
|
||||
let compose = `services:
|
||||
app:
|
||||
build:
|
||||
context: ./src
|
||||
dockerfile: Dockerfile
|
||||
container_name: ${appId}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- web`;
|
||||
|
||||
// Ajouter le réseau interne si base de données
|
||||
if (needsDb) {
|
||||
compose += `
|
||||
- ${appId}-internal`;
|
||||
}
|
||||
|
||||
// Variables d'environnement
|
||||
compose += `
|
||||
environment:
|
||||
- NODE_ENV=production`;
|
||||
|
||||
if (needsDb) {
|
||||
compose += `
|
||||
- DATABASE_HOST=${appId}-db
|
||||
- DATABASE_PORT=${dbType === 'postgres' ? '5432' : '3306'}
|
||||
- DATABASE_NAME=${appId.replace(/-/g, '_')}
|
||||
- DATABASE_USER=app_user
|
||||
- DATABASE_PASSWORD=AppPassword2026!
|
||||
- DB_HOST=${appId}-db
|
||||
- DB_PORT=${dbType === 'postgres' ? '5432' : '3306'}
|
||||
- DB_NAME=${appId.replace(/-/g, '_')}
|
||||
- DB_USER=app_user
|
||||
- DB_PASSWORD=AppPassword2026!
|
||||
- MYSQL_HOST=${appId}-db
|
||||
- MYSQL_DATABASE=${appId.replace(/-/g, '_')}
|
||||
- MYSQL_USER=app_user
|
||||
- MYSQL_PASSWORD=AppPassword2026!`;
|
||||
}
|
||||
|
||||
compose += `
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.${appId}.rule=Host(\`${domain}\`)"
|
||||
- "traefik.http.routers.${appId}.entrypoints=websecure"
|
||||
- "traefik.http.routers.${appId}.tls=true"
|
||||
- "traefik.http.routers.${appId}.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.${appId}.service=${appId}-svc"
|
||||
- "traefik.http.services.${appId}-svc.loadbalancer.server.port=${containerPort}"
|
||||
- "traefik.docker.network=web"`;
|
||||
|
||||
// Ajouter la base de données si nécessaire
|
||||
if (needsDb) {
|
||||
const dbName = appId.replace(/-/g, '_');
|
||||
|
||||
if (dbType === 'postgres') {
|
||||
compose += `
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: ${appId}-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_DB=${dbName}
|
||||
- POSTGRES_USER=app_user
|
||||
- POSTGRES_PASSWORD=AppPassword2026!
|
||||
volumes:
|
||||
- ${appId}-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- ${appId}-internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U app_user -d ${dbName}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5`;
|
||||
} else {
|
||||
compose += `
|
||||
|
||||
db:
|
||||
image: mysql:8.0
|
||||
container_name: ${appId}-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=RootPassword2026!
|
||||
- MYSQL_DATABASE=${dbName}
|
||||
- MYSQL_USER=app_user
|
||||
- MYSQL_PASSWORD=AppPassword2026!
|
||||
volumes:
|
||||
- ${appId}-db-data:/var/lib/mysql
|
||||
networks:
|
||||
- ${appId}-internal
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pRootPassword2026!"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5`;
|
||||
}
|
||||
}
|
||||
|
||||
// Dépendance app -> db
|
||||
if (needsDb) {
|
||||
// Insert depends_on before labels
|
||||
const appSection = compose.indexOf(' labels:');
|
||||
const dependsOn = ` depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
`;
|
||||
compose = compose.slice(0, appSection) + dependsOn + compose.slice(appSection);
|
||||
}
|
||||
|
||||
// Réseaux
|
||||
compose += `
|
||||
|
||||
networks:
|
||||
web:
|
||||
external: true`;
|
||||
|
||||
if (needsDb) {
|
||||
compose += `
|
||||
${appId}-internal:
|
||||
driver: bridge`;
|
||||
}
|
||||
|
||||
// Volumes
|
||||
if (needsDb) {
|
||||
compose += `
|
||||
|
||||
volumes:
|
||||
${appId}-db-data:`;
|
||||
}
|
||||
|
||||
compose += '\n';
|
||||
|
||||
return compose;
|
||||
}
|
||||
|
||||
function getContainerPort(stack, customPort) {
|
||||
if (customPort) return customPort;
|
||||
const defaults = {
|
||||
nodejs: 3000,
|
||||
'nodejs-next': 3000,
|
||||
python: 5000,
|
||||
'python-django': 8000,
|
||||
'python-flask': 5000,
|
||||
php: 80,
|
||||
'php-laravel': 9000,
|
||||
static: 80,
|
||||
'static-react': 80,
|
||||
};
|
||||
return defaults[stack] || 3000;
|
||||
}
|
||||
|
||||
// ============ APPS PERSISTENCE ============
|
||||
|
||||
const APPS_FILE = path.join(config.appsBasePath, '.dashboard-apps.json');
|
||||
|
||||
function loadDynamicApps() {
|
||||
try {
|
||||
if (fs.existsSync(APPS_FILE)) {
|
||||
const data = fs.readFileSync(APPS_FILE, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement apps dynamiques:', err.message);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveDynamicApps(apps) {
|
||||
try {
|
||||
fs.writeFileSync(APPS_FILE, JSON.stringify(apps, null, 2), 'utf-8');
|
||||
} catch (err) {
|
||||
console.error('Erreur sauvegarde apps dynamiques:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function addDynamicApp(appDef) {
|
||||
const apps = loadDynamicApps();
|
||||
// Éviter les doublons
|
||||
const existing = apps.findIndex((a) => a.id === appDef.id);
|
||||
if (existing >= 0) {
|
||||
apps[existing] = appDef;
|
||||
} else {
|
||||
apps.push(appDef);
|
||||
}
|
||||
saveDynamicApps(apps);
|
||||
|
||||
// Ajouter aussi à config.apps en mémoire
|
||||
const existingInConfig = config.apps.findIndex((a) => a.id === appDef.id);
|
||||
if (existingInConfig >= 0) {
|
||||
config.apps[existingInConfig] = appDef;
|
||||
} else {
|
||||
config.apps.push(appDef);
|
||||
}
|
||||
}
|
||||
|
||||
function initDynamicApps() {
|
||||
const dynamicApps = loadDynamicApps();
|
||||
for (const app of dynamicApps) {
|
||||
const existingInConfig = config.apps.findIndex((a) => a.id === app.id);
|
||||
if (existingInConfig < 0) {
|
||||
config.apps.push(app);
|
||||
}
|
||||
}
|
||||
console.log(`Apps dynamiques chargées: ${dynamicApps.length}`);
|
||||
}
|
||||
|
||||
// ============ CREATION D'APPLICATION ============
|
||||
|
||||
async function createApplication(params, logCallback) {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
subdomain,
|
||||
stack,
|
||||
port,
|
||||
needsDb,
|
||||
dbType,
|
||||
sourceType, // 'zip' or 'gitea'
|
||||
giteaRepoUrl, // URL du repo Gitea existant
|
||||
zipFilePath, // Chemin du fichier ZIP uploadé
|
||||
} = params;
|
||||
|
||||
const appId = subdomain;
|
||||
const domain = `${subdomain}.recette.santinova-soft.org`;
|
||||
const appDir = path.join(config.appsBasePath, appId);
|
||||
const srcDir = path.join(appDir, 'src');
|
||||
|
||||
const log = (msg) => {
|
||||
const line = `[${new Date().toISOString()}] ${msg}`;
|
||||
console.log(line);
|
||||
if (logCallback) logCallback(line);
|
||||
};
|
||||
|
||||
try {
|
||||
// 1. Créer le répertoire de l'application
|
||||
log(`Création du répertoire ${appDir}...`);
|
||||
await fsp.mkdir(appDir, { recursive: true });
|
||||
await fsp.mkdir(srcDir, { recursive: true });
|
||||
|
||||
let giteaRepoName = appId;
|
||||
let giteaOwner = config.gitea.username;
|
||||
|
||||
if (sourceType === 'gitea') {
|
||||
// 2a. Cloner depuis un dépôt Gitea existant
|
||||
log(`Clonage du dépôt Gitea: ${giteaRepoUrl}...`);
|
||||
const cloneUrl = giteaRepoUrl.replace(
|
||||
'https://',
|
||||
`https://${config.gitea.username}:${encodeURIComponent(config.gitea.password)}@`
|
||||
);
|
||||
await execCommand(`cd ${appDir} && rm -rf src && git clone ${cloneUrl} src`, 120000);
|
||||
log('Clonage terminé.');
|
||||
|
||||
// Extraire le nom du repo depuis l'URL
|
||||
const urlParts = giteaRepoUrl.replace(/\.git$/, '').split('/');
|
||||
giteaRepoName = urlParts[urlParts.length - 1];
|
||||
if (urlParts.length >= 2) {
|
||||
giteaOwner = urlParts[urlParts.length - 2];
|
||||
}
|
||||
} else if (sourceType === 'zip') {
|
||||
// 2b. Extraire le ZIP et créer un dépôt Gitea
|
||||
log(`Extraction du fichier ZIP...`);
|
||||
await execCommand(`cd ${srcDir} && unzip -o ${zipFilePath}`, 60000);
|
||||
|
||||
// Vérifier si les fichiers sont dans un sous-répertoire
|
||||
const entries = await fsp.readdir(srcDir);
|
||||
const nonHidden = entries.filter((e) => !e.startsWith('.') && e !== '__MACOSX');
|
||||
if (nonHidden.length === 1) {
|
||||
const subDir = path.join(srcDir, nonHidden[0]);
|
||||
const stat = await fsp.stat(subDir);
|
||||
if (stat.isDirectory()) {
|
||||
log(`Déplacement des fichiers depuis le sous-répertoire ${nonHidden[0]}...`);
|
||||
await execCommand(`cd ${subDir} && mv * ${srcDir}/ 2>/dev/null; mv .* ${srcDir}/ 2>/dev/null; cd ${srcDir} && rmdir "${nonHidden[0]}" 2>/dev/null || true`);
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer le ZIP temporaire
|
||||
try { await fsp.unlink(zipFilePath); } catch (e) { /* ignore */ }
|
||||
|
||||
// Créer le dépôt sur Gitea
|
||||
log(`Création du dépôt Gitea: ${appId}...`);
|
||||
try {
|
||||
await createRepo(appId, description || `Application ${name}`, true);
|
||||
log('Dépôt Gitea créé.');
|
||||
} catch (err) {
|
||||
if (err.response && err.response.status === 409) {
|
||||
log('Le dépôt Gitea existe déjà, utilisation du dépôt existant.');
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialiser git et pousser le code
|
||||
log('Initialisation Git et push vers Gitea...');
|
||||
const gitUrl = `https://${config.gitea.username}:${encodeURIComponent(config.gitea.password)}@${config.gitea.url.replace('https://', '')}/${config.gitea.username}/${appId}.git`;
|
||||
await execCommand(
|
||||
`cd ${srcDir} && git init && git checkout -b main && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add -A && git commit -m "Initial commit: ${name}" && git remote add origin ${gitUrl} && git push -u origin main --force`,
|
||||
120000
|
||||
);
|
||||
log('Code poussé sur Gitea.');
|
||||
}
|
||||
|
||||
// 3. Générer le Dockerfile si nécessaire
|
||||
const dockerfilePath = path.join(srcDir, 'Dockerfile');
|
||||
const hasDockerfile = fs.existsSync(dockerfilePath);
|
||||
|
||||
if (!hasDockerfile) {
|
||||
log(`Génération du Dockerfile pour la stack ${stack}...`);
|
||||
const containerPort = getContainerPort(stack, port);
|
||||
const templateFn = DOCKERFILE_TEMPLATES[stack] || DOCKERFILE_TEMPLATES['nodejs'];
|
||||
let dockerfileContent = templateFn(containerPort);
|
||||
|
||||
// If the project does NOT have a patches/ directory, remove COPY patches/ lines
|
||||
const hasPatchesDir = fs.existsSync(path.join(srcDir, 'patches'));
|
||||
if (!hasPatchesDir) {
|
||||
dockerfileContent = dockerfileContent
|
||||
.split('\n')
|
||||
.filter(line => !line.includes('COPY patches/'))
|
||||
.join('\n');
|
||||
log('Pas de dossier patches/ détecté, lignes COPY patches/ retirées du Dockerfile.');
|
||||
}
|
||||
|
||||
// If the project does NOT have a drizzle/ directory, remove COPY drizzle lines
|
||||
const hasDrizzleDir = fs.existsSync(path.join(srcDir, 'drizzle'));
|
||||
if (!hasDrizzleDir) {
|
||||
dockerfileContent = dockerfileContent
|
||||
.split('\n')
|
||||
.filter(line => !line.includes('COPY drizzle'))
|
||||
.join('\n');
|
||||
log('Pas de dossier drizzle/ détecté, lignes COPY drizzle retirées du Dockerfile.');
|
||||
}
|
||||
|
||||
await fsp.writeFile(dockerfilePath, dockerfileContent, 'utf-8');
|
||||
log('Dockerfile généré.');
|
||||
|
||||
// Commit le Dockerfile
|
||||
if (sourceType === 'zip') {
|
||||
await execCommand(
|
||||
`cd ${srcDir} && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add Dockerfile && git commit -m "Add auto-generated Dockerfile" && git push origin main`,
|
||||
60000
|
||||
);
|
||||
log('Dockerfile commité et poussé sur Gitea.');
|
||||
}
|
||||
} else {
|
||||
log('Dockerfile existant détecté, utilisation du Dockerfile du projet.');
|
||||
}
|
||||
|
||||
// 4. Générer le docker-compose.yml
|
||||
log('Génération du docker-compose.yml...');
|
||||
const containerPort = getContainerPort(stack, port);
|
||||
const composeContent = generateDockerCompose({
|
||||
appId,
|
||||
subdomain,
|
||||
stack,
|
||||
port: containerPort,
|
||||
needsDb: needsDb || false,
|
||||
dbType: dbType || 'mysql',
|
||||
});
|
||||
await fsp.writeFile(path.join(appDir, 'docker-compose.yml'), composeContent, 'utf-8');
|
||||
log('docker-compose.yml généré.');
|
||||
|
||||
// 5. Lancer le build et le déploiement Docker
|
||||
log('Lancement du build Docker (cela peut prendre quelques minutes)...');
|
||||
const buildResult = await execCommand(
|
||||
`cd ${appDir} && docker compose build --no-cache 2>&1`,
|
||||
600000
|
||||
);
|
||||
log('Build Docker terminé.');
|
||||
|
||||
log('Démarrage des conteneurs...');
|
||||
await execCommand(`cd ${appDir} && docker compose up -d 2>&1`, 120000);
|
||||
log('Conteneurs démarrés.');
|
||||
|
||||
// 6. Créer un utilisateur admin dans la base de données si nécessaire
|
||||
if (needsDb) {
|
||||
log('Attente du démarrage de la base de données (15s)...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 15000));
|
||||
|
||||
try {
|
||||
log('Tentative de création de l\'utilisateur admin dans la base de données...');
|
||||
if (dbType === 'postgres') {
|
||||
await execCommand(
|
||||
`docker exec ${appId}-db psql -U app_user -d ${appId.replace(/-/g, '_')} -c "CREATE TABLE IF NOT EXISTS admin_users (id SERIAL PRIMARY KEY, email VARCHAR(255), password VARCHAR(255), role VARCHAR(50), created_at TIMESTAMP DEFAULT NOW()); INSERT INTO admin_users (email, password, role) VALUES ('adminItinova@santinova-soft.org', 'Itinova69!', 'admin') ON CONFLICT DO NOTHING;" 2>&1`,
|
||||
30000
|
||||
);
|
||||
} else {
|
||||
await execCommand(
|
||||
`docker exec ${appId}-db mysql -uapp_user -pAppPassword2026! ${appId.replace(/-/g, '_')} -e "CREATE TABLE IF NOT EXISTS admin_users (id INT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255), password VARCHAR(255), role VARCHAR(50), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); INSERT IGNORE INTO admin_users (email, password, role) VALUES ('adminItinova@santinova-soft.org', 'Itinova69!', 'admin');" 2>&1`,
|
||||
30000
|
||||
);
|
||||
}
|
||||
log('Utilisateur admin créé dans la base de données.');
|
||||
} catch (dbErr) {
|
||||
log(`Note: Création de l'utilisateur admin en BDD non critique: ${dbErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Enregistrer l'application dans la configuration dynamique
|
||||
const appDef = {
|
||||
id: appId,
|
||||
name: name,
|
||||
description: description || `Application ${name}`,
|
||||
directory: appId,
|
||||
giteaRepo: giteaRepoName,
|
||||
giteaOwner: giteaOwner,
|
||||
urls: {
|
||||
recette: `https://${domain}`,
|
||||
prod: null,
|
||||
},
|
||||
containerName: appId,
|
||||
healthCheckUrl: `https://${domain}`,
|
||||
port: containerPort,
|
||||
stack: stack,
|
||||
needsDb: needsDb || false,
|
||||
dbType: dbType || null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
addDynamicApp(appDef);
|
||||
log(`Application ${name} enregistrée dans le dashboard.`);
|
||||
log(`Accessible sur: https://${domain}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
app: appDef,
|
||||
message: `Application ${name} créée et déployée avec succès`,
|
||||
url: `https://${domain}`,
|
||||
};
|
||||
} catch (err) {
|
||||
log(`ERREUR: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function execCommand(cmd, timeout = 60000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(cmd, { maxBuffer: 1024 * 1024 * 50, timeout }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`Commande échouée: ${error.message}\nStdout: ${stdout}\nStderr: ${stderr}`));
|
||||
} else {
|
||||
resolve(stdout + stderr);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============ STACK DETECTION ============
|
||||
|
||||
function getAvailableStacks() {
|
||||
return [
|
||||
{ id: 'nodejs', name: 'Node.js', icon: 'nodejs', description: 'Application Node.js (Express, Fastify, etc.)' },
|
||||
{ id: 'nodejs-next', name: 'Node.js (Next.js)', icon: 'nodejs', description: 'Application Next.js avec SSR' },
|
||||
{ id: 'python', name: 'Python', icon: 'python', description: 'Application Python générique' },
|
||||
{ id: 'python-flask', name: 'Python (Flask)', icon: 'python', description: 'Application Flask' },
|
||||
{ id: 'python-django', name: 'Python (Django)', icon: 'python', description: 'Application Django' },
|
||||
{ id: 'php', name: 'PHP', icon: 'php', description: 'Application PHP avec Apache' },
|
||||
{ id: 'php-laravel', name: 'PHP (Laravel)', icon: 'php', description: 'Application Laravel' },
|
||||
{ id: 'static', name: 'Site statique', icon: 'static', description: 'HTML/CSS/JS statique (Nginx)' },
|
||||
{ id: 'static-react', name: 'React (SPA)', icon: 'react', description: 'Application React avec build (Vite/CRA)' },
|
||||
];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createApplication,
|
||||
generateDockerCompose,
|
||||
getAvailableStacks,
|
||||
initDynamicApps,
|
||||
loadDynamicApps,
|
||||
addDynamicApp,
|
||||
DOCKERFILE_TEMPLATES,
|
||||
};
|
||||
61
backend/src/auth.js
Normal file
61
backend/src/auth.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const config = require('./config');
|
||||
|
||||
// Hash du mot de passe au démarrage
|
||||
let passwordHash = null;
|
||||
|
||||
async function initAuth() {
|
||||
passwordHash = await bcrypt.hash(config.auth.password, 10);
|
||||
}
|
||||
|
||||
async function authenticate(username, password) {
|
||||
if (username !== config.auth.username) {
|
||||
return null;
|
||||
}
|
||||
// Comparer directement car on stocke le mot de passe en clair dans la config
|
||||
if (password !== config.auth.password) {
|
||||
return null;
|
||||
}
|
||||
const token = jwt.sign(
|
||||
{ username, role: 'admin' },
|
||||
config.jwtSecret,
|
||||
{ expiresIn: config.jwtExpiry }
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
function verifyToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, config.jwtSecret);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
// Check Authorization header
|
||||
let token = null;
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7);
|
||||
}
|
||||
// Check cookie
|
||||
if (!token && req.cookies) {
|
||||
token = req.cookies.dashboard_token;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Non authentifié' });
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token);
|
||||
if (!decoded) {
|
||||
return res.status(401).json({ error: 'Token invalide ou expiré' });
|
||||
}
|
||||
|
||||
req.user = decoded;
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { initAuth, authenticate, verifyToken, authMiddleware };
|
||||
230
backend/src/config.js
Normal file
230
backend/src/config.js
Normal file
@@ -0,0 +1,230 @@
|
||||
module.exports = {
|
||||
port: process.env.PORT || 3001,
|
||||
jwtSecret: process.env.JWT_SECRET || 'manus-dashboard-secret-2026',
|
||||
jwtExpiry: '24h',
|
||||
// Authentification
|
||||
auth: {
|
||||
username: process.env.ADMIN_USERNAME || 'adminItinova',
|
||||
password: process.env.ADMIN_PASSWORD || 'Itinova69!',
|
||||
},
|
||||
// Gitea
|
||||
gitea: {
|
||||
url: process.env.GITEA_URL || 'http://gitea:3000',
|
||||
username: process.env.GITEA_USERNAME || 'manus-admin',
|
||||
password: process.env.GITEA_PASSWORD || 'Itinova69!',
|
||||
token: process.env.GITEA_TOKEN || null,
|
||||
},
|
||||
// Applications config
|
||||
appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps',
|
||||
infrastructurePath: process.env.INFRA_BASE_PATH || '/opt/manus-deploy/infrastructure',
|
||||
// Health check interval (ms)
|
||||
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 30000,
|
||||
// Apps configuration
|
||||
apps: [
|
||||
{
|
||||
id: 'itinova-contacts',
|
||||
name: 'Itinova Contacts',
|
||||
description: 'Application de gestion des contacts Itinova',
|
||||
directory: 'itinova-contacts',
|
||||
giteaRepo: 'itinova-contacts',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://contacts.recette.santinova-soft.org',
|
||||
prod: 'https://contacts.santinova-soft.org',
|
||||
},
|
||||
containerName: 'itinova-contacts',
|
||||
healthCheckUrl: 'https://contacts.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'ITINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'itinova-podcasts',
|
||||
name: 'Itinova Podcasts',
|
||||
description: 'Gestionnaire de podcasts pour les établissements Itinova',
|
||||
directory: 'itinova-podcasts',
|
||||
giteaRepo: 'itinova-podcasts',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://podcasts.recette.santinova-soft.org',
|
||||
prod: 'https://podcasts.santinova-soft.org',
|
||||
},
|
||||
containerName: 'itinova-podcasts',
|
||||
healthCheckUrl: 'https://podcasts.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'ITINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'veille-reglementaire',
|
||||
name: 'Veille Réglementaire',
|
||||
description: 'Application de veille réglementaire et appels à projets',
|
||||
directory: 'veille-reglementaire',
|
||||
giteaRepo: 'veille-reglementaire',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://veille.recette.santinova-soft.org',
|
||||
prod: 'https://veille.santinova-soft.org',
|
||||
},
|
||||
containerName: 'veille-reglementaire-recette',
|
||||
healthCheckUrl: 'https://veille.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'ITINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'itinova-vehicle-exchange',
|
||||
name: 'Itinova Gestion de Flotte',
|
||||
description: 'Bourse de véhicules Itinova — Partage et échange de véhicules entre établissements',
|
||||
directory: 'itinova-vehicle-exchange',
|
||||
giteaRepo: 'itinova-vehicle-exchange',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://flotte.recette.santinova-soft.org',
|
||||
prod: 'https://flotte.santinova-soft.org',
|
||||
},
|
||||
containerName: 'itinova-vehicle-exchange',
|
||||
healthCheckUrl: 'https://flotte.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'ITINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'pilotage-masse-salariale',
|
||||
name: 'Pilotage Masse Salariale',
|
||||
description: 'Application de pilotage de la masse salariale Itinova',
|
||||
directory: 'pilotage-masse-salariale',
|
||||
giteaRepo: 'pilotage-masse-salariale',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://pilotage-ms.recette.santinova-soft.org',
|
||||
prod: 'https://pilotage-ms.santinova-soft.org',
|
||||
},
|
||||
containerName: 'pilotage-masse-salariale-app',
|
||||
healthCheckUrl: 'https://pilotage-ms.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'ITINOVA',
|
||||
status: 'recette',
|
||||
},
|
||||
{
|
||||
id: 'sonum',
|
||||
name: 'SONUM',
|
||||
description: 'Cartographie des Solutions Numériques FEHAP',
|
||||
directory: 'sonum',
|
||||
giteaRepo: 'sonum',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://sonum.recette.santinova-soft.org',
|
||||
prod: 'https://sonum.santinova-soft.org',
|
||||
},
|
||||
containerName: 'sonum',
|
||||
healthCheckUrl: 'https://sonum.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'SANTINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'demat-facturation-dsi',
|
||||
name: 'Démat. Facturation DSI',
|
||||
description: 'Dématérialisation des factures DSI — Import email/dossier, analyse IA, export SharePoint/PDF, validation BAP',
|
||||
directory: 'demat-facturation-dsi',
|
||||
giteaRepo: 'demat-facturation-dsi',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://demat-facturation.recette.santinova-soft.org',
|
||||
prod: 'https://demat-facturation.santinova-soft.org',
|
||||
},
|
||||
containerName: 'demat-facturation-app',
|
||||
healthCheckUrl: 'https://demat-facturation.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
image: 'images/demat-facturation-dsi.jpg',
|
||||
category: 'SANTINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'facturation-santinova',
|
||||
name: 'Facturation Santinova',
|
||||
description: 'Application de gestion de la facturation Santinova',
|
||||
directory: 'facturation-santinova',
|
||||
giteaRepo: 'facturation-santinova',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://facturation.recette.santinova-soft.org',
|
||||
prod: 'https://facturation.santinova-soft.org',
|
||||
},
|
||||
containerName: 'facturation-santinova-app',
|
||||
healthCheckUrl: 'https://facturation.recette.santinova-soft.org',
|
||||
port: 3001,
|
||||
category: 'SANTINOVA',
|
||||
status: 'recette',
|
||||
},
|
||||
{
|
||||
id: 'formation-manager-itinova',
|
||||
name: 'Formation Manager',
|
||||
description: 'Gestion des Formations Itinova',
|
||||
directory: 'formation-manager-itinova',
|
||||
giteaRepo: 'formation-manager-itinova',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://formations.recette.santinova-soft.org',
|
||||
prod: 'https://formations.itinova.org',
|
||||
},
|
||||
containerName: 'formation-manager-itinova',
|
||||
healthCheckUrl: 'https://formations.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'ITINOVA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'portail-santinova',
|
||||
name: 'Portail Applicatif',
|
||||
description: 'Portail applicatif Santinova — accès centralisé aux applications',
|
||||
directory: 'portail-santinova',
|
||||
giteaRepo: 'portail-santinova',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://portail.recette.santinova-soft.org',
|
||||
prod: 'https://portail.santinova-soft.org',
|
||||
},
|
||||
containerName: 'portail-santinova',
|
||||
healthCheckUrl: 'https://portail.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'INFRA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'manus-dashboard',
|
||||
name: 'Dashboard Recette',
|
||||
description: 'Dashboard de gestion de l\'infrastructure Manus',
|
||||
directory: 'manus-dashboard',
|
||||
giteaRepo: 'manus-dashboard',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://dashboard.recette.santinova-soft.org',
|
||||
prod: 'https://dashboard.santinova-soft.org',
|
||||
},
|
||||
containerName: 'manus-dashboard',
|
||||
healthCheckUrl: 'https://dashboard.recette.santinova-soft.org',
|
||||
port: 3001,
|
||||
category: 'INFRA',
|
||||
status: 'production',
|
||||
},
|
||||
{
|
||||
id: 'itinova-budget-si',
|
||||
name: 'Gestion Budget Informatique',
|
||||
description: 'Application de gestion du budget informatique DSI Itinova',
|
||||
directory: 'itinova-budget-si',
|
||||
giteaRepo: 'itinova-budget-si',
|
||||
giteaOwner: 'manus-admin',
|
||||
urls: {
|
||||
recette: 'https://budget-si.recette.santinova-soft.org',
|
||||
prod: 'https://budget-si.santinova-soft.org',
|
||||
},
|
||||
containerName: 'itinova-budget-si-app',
|
||||
healthCheckUrl: 'https://budget-si.recette.santinova-soft.org',
|
||||
port: 3000,
|
||||
category: 'SANTINOVA',
|
||||
status: 'recette',
|
||||
},
|
||||
],
|
||||
};
|
||||
411
backend/src/docker.js
Normal file
411
backend/src/docker.js
Normal file
@@ -0,0 +1,411 @@
|
||||
const Docker = require('dockerode');
|
||||
const { exec } = require('child_process');
|
||||
const path = require('path');
|
||||
const config = require('./config');
|
||||
|
||||
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
||||
|
||||
/**
|
||||
* Récupérer les informations d'un conteneur Docker
|
||||
*/
|
||||
async function getContainerInfo(containerName) {
|
||||
try {
|
||||
const container = docker.getContainer(containerName);
|
||||
const info = await container.inspect();
|
||||
return {
|
||||
id: info.Id.substring(0, 12),
|
||||
name: info.Name.replace('/', ''),
|
||||
state: info.State.Status,
|
||||
running: info.State.Running,
|
||||
startedAt: info.State.StartedAt,
|
||||
image: info.Config.Image,
|
||||
created: info.Created,
|
||||
};
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les logs d'un conteneur
|
||||
*/
|
||||
async function getContainerLogs(containerName, tail = 100) {
|
||||
try {
|
||||
const container = docker.getContainer(containerName);
|
||||
const logs = await container.logs({
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
tail: tail,
|
||||
timestamps: true,
|
||||
});
|
||||
// Parse buffer to string
|
||||
return logs.toString('utf8');
|
||||
} catch (err) {
|
||||
return `Erreur lors de la récupération des logs: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lister tous les conteneurs
|
||||
*/
|
||||
async function listContainers() {
|
||||
try {
|
||||
const containers = await docker.listContainers({ all: true });
|
||||
return containers.map((c) => ({
|
||||
id: c.Id.substring(0, 12),
|
||||
names: c.Names.map((n) => n.replace('/', '')),
|
||||
state: c.State,
|
||||
status: c.Status,
|
||||
image: c.Image,
|
||||
created: new Date(c.Created * 1000).toISOString(),
|
||||
}));
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redéployer une application (docker compose build + up)
|
||||
*/
|
||||
function redeployApp(appConfig) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const appDir = path.join(config.appsBasePath, appConfig.directory);
|
||||
const cmd = `cd ${appDir} && docker compose build --no-cache && docker compose up -d`;
|
||||
|
||||
const startTime = Date.now();
|
||||
const logLines = [];
|
||||
|
||||
logLines.push(`[${new Date().toISOString()}] Début du redéploiement de ${appConfig.name}`);
|
||||
logLines.push(`[${new Date().toISOString()}] Répertoire: ${appDir}`);
|
||||
logLines.push(`[${new Date().toISOString()}] Commande: ${cmd}`);
|
||||
|
||||
const process = exec(cmd, {
|
||||
maxBuffer: 1024 * 1024 * 10, // 10MB
|
||||
timeout: 300000, // 5 minutes
|
||||
});
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
const lines = data.toString().split('\n').filter(Boolean);
|
||||
lines.forEach((line) => {
|
||||
logLines.push(`[${new Date().toISOString()}] ${line}`);
|
||||
});
|
||||
});
|
||||
|
||||
process.stderr.on('data', (data) => {
|
||||
const lines = data.toString().split('\n').filter(Boolean);
|
||||
lines.forEach((line) => {
|
||||
logLines.push(`[${new Date().toISOString()}] ${line}`);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('close', (code) => {
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
if (code === 0) {
|
||||
logLines.push(`[${new Date().toISOString()}] Redéploiement terminé avec succès en ${duration}s`);
|
||||
resolve({ success: true, logs: logLines.join('\n'), duration });
|
||||
} else {
|
||||
logLines.push(`[${new Date().toISOString()}] Redéploiement échoué (code: ${code}) en ${duration}s`);
|
||||
resolve({ success: false, logs: logLines.join('\n'), duration, exitCode: code });
|
||||
}
|
||||
});
|
||||
|
||||
process.on('error', (err) => {
|
||||
logLines.push(`[${new Date().toISOString()}] Erreur: ${err.message}`);
|
||||
resolve({ success: false, logs: logLines.join('\n'), error: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull le dernier code depuis Gitea
|
||||
*/
|
||||
function gitPull(appConfig) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srcDir = path.join(config.appsBasePath, appConfig.directory, 'src');
|
||||
const cmd = `cd ${srcDir} && git pull origin main 2>&1 || git pull origin master 2>&1`;
|
||||
|
||||
exec(cmd, { timeout: 60000 }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
resolve({ success: false, output: stderr || error.message });
|
||||
} else {
|
||||
resolve({ success: true, output: stdout });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Démarrer un conteneur Docker
|
||||
*/
|
||||
async function startContainer(containerName) {
|
||||
try {
|
||||
const container = docker.getContainer(containerName);
|
||||
await container.start();
|
||||
return { success: true, message: `Conteneur ${containerName} démarré` };
|
||||
} catch (err) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrêter un conteneur Docker
|
||||
*/
|
||||
async function stopContainer(containerName) {
|
||||
try {
|
||||
const container = docker.getContainer(containerName);
|
||||
await container.stop({ t: 10 });
|
||||
return { success: true, message: `Conteneur ${containerName} arrêté` };
|
||||
} catch (err) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redémarrer un conteneur Docker
|
||||
*/
|
||||
async function restartContainer(containerName) {
|
||||
try {
|
||||
const container = docker.getContainer(containerName);
|
||||
await container.restart({ t: 10 });
|
||||
return { success: true, message: `Conteneur ${containerName} redémarré` };
|
||||
} catch (err) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les métriques système du serveur (CPU, RAM, disque, réseau)
|
||||
*/
|
||||
function getServerMetrics() {
|
||||
return new Promise((resolve) => {
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
try {
|
||||
// ===== Méthode 1 : Lire host-metrics.json généré par le script système de l'hôte =====
|
||||
// Ce fichier est mis à jour toutes les 30s par /opt/manus-deploy/host-metrics.sh
|
||||
// Il contient les vraies métriques de l'hôte LXC (pas du nœud physique)
|
||||
const hostMetricsPath = '/opt/manus-deploy/host-metrics.json';
|
||||
if (fs.existsSync(hostMetricsPath)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(hostMetricsPath, 'utf8');
|
||||
const hostMetrics = JSON.parse(raw);
|
||||
// Vérifier que le fichier est récent (moins de 2 minutes)
|
||||
const fileAge = Date.now() - new Date(hostMetrics.timestamp).getTime();
|
||||
if (fileAge < 120000 && hostMetrics.memory && hostMetrics.memory.total > 0) {
|
||||
return resolve({
|
||||
...hostMetrics,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ===== Méthode 2 : Lecture directe de /proc (fallback) =====
|
||||
// CPU : mesure via cgroup v2 usage_usec (méthode précise pour conteneur LXC)
|
||||
// Fallback sur /proc/stat normalisé par nproc si cgroup v2 non disponible
|
||||
let cpuUsage = 0;
|
||||
try {
|
||||
const ncpu = parseInt(execSync('nproc').toString().trim()) || 1;
|
||||
const cgroupPath = '/sys/fs/cgroup/cpu.stat';
|
||||
const readUsageUsec = () => {
|
||||
const stat = fs.readFileSync(cgroupPath, 'utf8');
|
||||
const m = stat.match(/^usage_usec\s+(\d+)/m);
|
||||
return m ? parseInt(m[1]) : null;
|
||||
};
|
||||
const u1 = readUsageUsec();
|
||||
if (u1 !== null) {
|
||||
// Méthode cgroup v2 : mesure la consommation CPU réelle du conteneur
|
||||
execSync('sleep 0.5');
|
||||
const u2 = readUsageUsec();
|
||||
// delta en microsecondes / (500ms * ncpu) = % CPU du conteneur
|
||||
const deltaUs = u2 - u1;
|
||||
cpuUsage = Math.min(100, Math.round(deltaUs / (500000 * ncpu) * 100));
|
||||
} else {
|
||||
// Fallback : /proc/stat normalisé par nproc
|
||||
const parseCpuLine = (line) => line.trim().split(/\s+/).slice(1).map(Number);
|
||||
const s1 = parseCpuLine(fs.readFileSync('/proc/stat', 'utf8').split('\n').find(l => l.startsWith('cpu ')));
|
||||
execSync('sleep 0.3');
|
||||
const s2 = parseCpuLine(fs.readFileSync('/proc/stat', 'utf8').split('\n').find(l => l.startsWith('cpu ')));
|
||||
const idle1 = s1[3], total1 = s1.reduce((a,b)=>a+b,0);
|
||||
const idle2 = s2[3], total2 = s2.reduce((a,b)=>a+b,0);
|
||||
const dIdle = idle2 - idle1, dTotal = total2 - total1;
|
||||
const rawCpu = dTotal > 0 ? (1 - dIdle / dTotal) * 100 : 0;
|
||||
// Normaliser par nproc pour éviter l'effet nœud physique sur LXC
|
||||
cpuUsage = Math.round(rawCpu / ncpu);
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// RAM : lecture robuste multi-source
|
||||
// Priorité 1 : /host/proc/meminfo (hôte LXC monté via volume docker-compose)
|
||||
// Priorité 2 : /proc/meminfo si MemTotal <= 64 Go (lxcfs ou VPS direct)
|
||||
// Priorité 3 : Docker API MemTotal + ratio /proc/meminfo du nœud physique
|
||||
let memTotal = 0;
|
||||
let memFree = 0, memAvailable = 0, memUsed = 0, memBuffers = 0, memCached = 0;
|
||||
|
||||
// Fonction de lecture de meminfo depuis un chemin donné
|
||||
const readMeminfo = (procPath) => {
|
||||
const raw = fs.readFileSync(procPath + '/meminfo', 'utf8');
|
||||
const map = {};
|
||||
raw.split('\n').forEach(line => {
|
||||
const idx = line.indexOf(':');
|
||||
if (idx > 0) {
|
||||
const key = line.substring(0, idx).trim();
|
||||
const rest = line.substring(idx + 1).trim();
|
||||
const val = parseInt(rest.split(' ')[0]);
|
||||
if (Number.isFinite(val)) map[key] = val * 1024;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
};
|
||||
|
||||
try {
|
||||
let memMap = null;
|
||||
let sourceLabel = '';
|
||||
|
||||
// Priorité 1 : /host/proc monté depuis l'hôte LXC
|
||||
if (fs.existsSync('/host/proc/meminfo')) {
|
||||
try {
|
||||
const hostMap = readMeminfo('/host/proc');
|
||||
if (hostMap['MemTotal'] > 0 && hostMap['MemTotal'] <= 64 * 1024 * 1024 * 1024) {
|
||||
memMap = hostMap;
|
||||
sourceLabel = 'host/proc';
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// Priorité 2 : /proc direct si MemTotal <= 64 Go
|
||||
if (!memMap) {
|
||||
try {
|
||||
const procMap = readMeminfo('/proc');
|
||||
if (procMap['MemTotal'] > 0 && procMap['MemTotal'] <= 64 * 1024 * 1024 * 1024) {
|
||||
memMap = procMap;
|
||||
sourceLabel = '/proc direct';
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// Priorité 3 : Docker API MemTotal + ratio du nœud physique
|
||||
if (!memMap) {
|
||||
try {
|
||||
const dockerInfoRaw = execSync(
|
||||
'curl -s --unix-socket /var/run/docker.sock http://localhost/info',
|
||||
{ timeout: 3000 }
|
||||
).toString();
|
||||
const dockerInfo = JSON.parse(dockerInfoRaw);
|
||||
const dockerMemTotal = dockerInfo.MemTotal || 0;
|
||||
if (dockerMemTotal > 0) {
|
||||
// Lire le ratio d'utilisation depuis /proc du nœud physique
|
||||
const nodeMap = readMeminfo('/proc');
|
||||
const nodeTotal = nodeMap['MemTotal'] || 1;
|
||||
const nodeAvail = nodeMap['MemAvailable'] || 0;
|
||||
const usageRatio = (nodeTotal - nodeAvail) / nodeTotal;
|
||||
// Appliquer ce ratio à la vraie RAM du VPS
|
||||
memTotal = dockerMemTotal;
|
||||
memUsed = Math.round(memTotal * usageRatio);
|
||||
memFree = memTotal - memUsed;
|
||||
memAvailable = memFree;
|
||||
sourceLabel = 'docker-api+ratio';
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// Calculer les valeurs finales si memMap disponible
|
||||
if (memMap) {
|
||||
memTotal = memMap['MemTotal'] || 0;
|
||||
memFree = memMap['MemFree'] || 0;
|
||||
memAvailable = memMap['MemAvailable'] || 0;
|
||||
memBuffers = memMap['Buffers'] || 0;
|
||||
const sReclaimable = memMap['SReclaimable'] || 0;
|
||||
const shmem = memMap['Shmem'] || 0;
|
||||
memCached = (memMap['Cached'] || 0) + (sReclaimable > shmem ? sReclaimable - shmem : 0);
|
||||
// Utiliser MemAvailable comme référence car elle inclut les caches libérables
|
||||
// Identique à ce que `free` affiche dans la colonne 'disponible'
|
||||
memUsed = memTotal - memAvailable;
|
||||
if (memUsed < 0) memUsed = 0;
|
||||
}
|
||||
} catch(e) {
|
||||
// Fallback ultime
|
||||
try {
|
||||
const m = readMeminfo('/proc');
|
||||
memTotal = m['MemTotal'] || 0;
|
||||
memFree = m['MemFree'] || 0;
|
||||
memAvailable = m['MemAvailable'] || 0;
|
||||
memUsed = memTotal - memAvailable;
|
||||
} catch(e2) {}
|
||||
}
|
||||
|
||||
// Disque : df sur /opt/manus-deploy (point de montage de l'hôte LXC)
|
||||
// Utiliser /opt/manus-deploy car c'est un volume monté depuis l'hôte
|
||||
let diskTotal = 0, diskUsed = 0, diskFree = 0;
|
||||
try {
|
||||
const diskPath = fs.existsSync('/opt/manus-deploy') ? '/opt/manus-deploy' : '/';
|
||||
const diskRaw = execSync('df -B1 ' + diskPath + ' | tail -1').toString().trim().split(/\s+/);
|
||||
diskTotal = parseInt(diskRaw[1]);
|
||||
diskUsed = parseInt(diskRaw[2]);
|
||||
diskFree = parseInt(diskRaw[3]);
|
||||
} catch(e) {}
|
||||
|
||||
// Uptime et load : utiliser /host/proc si disponible (hôte LXC réel)
|
||||
const procBase = fs.existsSync('/host/proc/uptime') ? '/host/proc' : '/proc';
|
||||
const uptimeRaw = fs.readFileSync(procBase + '/uptime', 'utf8').trim().split(' ');
|
||||
const uptimeSeconds = parseFloat(uptimeRaw[0]);
|
||||
const loadRaw = fs.readFileSync(procBase + '/loadavg', 'utf8').trim().split(' ');
|
||||
const load1 = parseFloat(loadRaw[0]);
|
||||
const load5 = parseFloat(loadRaw[1]);
|
||||
const load15 = parseFloat(loadRaw[2]);
|
||||
|
||||
// Réseau
|
||||
let netRx = 0, netTx = 0;
|
||||
try {
|
||||
const netDevPath = fs.existsSync('/host/proc/net/dev') ? '/host/proc/net/dev' : '/proc/net/dev';
|
||||
const netDev = fs.readFileSync(netDevPath, 'utf8');
|
||||
const netLine = netDev.split('\n').find(l => /eth0|ens|enp/.test(l));
|
||||
if (netLine) {
|
||||
const parts = netLine.trim().split(/\s+/);
|
||||
netRx = parseInt(parts[1]) || 0;
|
||||
netTx = parseInt(parts[9]) || 0;
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
resolve({
|
||||
cpu: { usage: cpuUsage },
|
||||
memory: {
|
||||
total: memTotal,
|
||||
used: memUsed,
|
||||
free: memFree,
|
||||
available: memAvailable,
|
||||
buffers: memBuffers,
|
||||
cached: memCached,
|
||||
// usagePercent = RAM applicative réelle (hors cache disque, identique à `free`)
|
||||
usagePercent: Math.round((memUsed / memTotal) * 100)
|
||||
},
|
||||
disk: {
|
||||
total: diskTotal,
|
||||
used: diskUsed,
|
||||
free: diskFree,
|
||||
usagePercent: Math.round((diskUsed / diskTotal) * 100)
|
||||
},
|
||||
uptime: uptimeSeconds,
|
||||
load: { load1, load5, load15 },
|
||||
network: { rx: netRx, tx: netTx },
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (err) {
|
||||
resolve({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
module.exports = {
|
||||
docker,
|
||||
getContainerInfo,
|
||||
getContainerLogs,
|
||||
listContainers,
|
||||
redeployApp,
|
||||
gitPull,
|
||||
startContainer,
|
||||
stopContainer,
|
||||
restartContainer,
|
||||
getServerMetrics,
|
||||
};
|
||||
119
backend/src/gitea.js
Normal file
119
backend/src/gitea.js
Normal file
@@ -0,0 +1,119 @@
|
||||
const axios = require('axios');
|
||||
const https = require('https');
|
||||
const config = require('./config');
|
||||
|
||||
// Créer un client axios pour Gitea (ignorer les certificats auto-signés si nécessaire)
|
||||
// Utiliser le token API si disponible, sinon auth basique
|
||||
const giteaClientHeaders = config.gitea.token
|
||||
? { 'Authorization': `token ${config.gitea.token}` }
|
||||
: {};
|
||||
|
||||
const giteaClient = axios.create({
|
||||
baseURL: `${config.gitea.url}/api/v1`,
|
||||
...(config.gitea.token
|
||||
? { headers: giteaClientHeaders }
|
||||
: { auth: { username: config.gitea.username, password: config.gitea.password } }
|
||||
),
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Récupérer les informations d'un dépôt
|
||||
*/
|
||||
async function getRepo(owner, repo) {
|
||||
try {
|
||||
const response = await giteaClient.get(`/repos/${owner}/${repo}`);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
console.error(`Erreur Gitea getRepo ${owner}/${repo}:`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les derniers commits d'un dépôt
|
||||
*/
|
||||
async function getCommits(owner, repo, limit = 10) {
|
||||
try {
|
||||
const response = await giteaClient.get(`/repos/${owner}/${repo}/commits`, {
|
||||
params: { limit, page: 1 },
|
||||
});
|
||||
return response.data.map((c) => ({
|
||||
sha: c.sha,
|
||||
shortSha: c.sha.substring(0, 7),
|
||||
message: c.commit.message,
|
||||
author: c.commit.author.name,
|
||||
date: c.commit.author.date,
|
||||
url: c.html_url,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`Erreur Gitea getCommits ${owner}/${repo}:`, err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les branches d'un dépôt
|
||||
*/
|
||||
async function getBranches(owner, repo) {
|
||||
try {
|
||||
const response = await giteaClient.get(`/repos/${owner}/${repo}/branches`);
|
||||
return response.data.map((b) => ({
|
||||
name: b.name,
|
||||
commit: b.commit.id.substring(0, 7),
|
||||
commitMessage: b.commit.message,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`Erreur Gitea getBranches ${owner}/${repo}:`, err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer tous les dépôts (avec retry en cas d'erreur DNS)
|
||||
*/
|
||||
async function listRepos(retries = 3) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const response = await giteaClient.get('/repos/search', {
|
||||
params: { limit: 50 },
|
||||
});
|
||||
return response.data.data || [];
|
||||
} catch (err) {
|
||||
console.error(`Erreur Gitea listRepos (tentative ${i + 1}/${retries}):`, err.message);
|
||||
if (i < retries - 1) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Créer un nouveau dépôt
|
||||
*/
|
||||
async function createRepo(name, description, isPrivate = true) {
|
||||
try {
|
||||
const response = await giteaClient.post('/user/repos', {
|
||||
name,
|
||||
description,
|
||||
private: isPrivate,
|
||||
auto_init: true,
|
||||
default_branch: 'main',
|
||||
});
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
console.error(`Erreur Gitea createRepo ${name}:`, err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getRepo,
|
||||
getCommits,
|
||||
getBranches,
|
||||
listRepos,
|
||||
createRepo,
|
||||
giteaClient,
|
||||
};
|
||||
188
backend/src/healthcheck.js
Normal file
188
backend/src/healthcheck.js
Normal file
@@ -0,0 +1,188 @@
|
||||
const axios = require('axios');
|
||||
const https = require('https');
|
||||
const config = require('./config');
|
||||
const { getContainerInfo } = require('./docker');
|
||||
|
||||
// Store pour les statuts des apps
|
||||
const appStatuses = new Map();
|
||||
|
||||
// Store pour les logs de déploiement
|
||||
const deploymentLogs = [];
|
||||
const MAX_DEPLOYMENT_LOGS = 50;
|
||||
|
||||
// Store pour les commits Gitea
|
||||
const giteaCommits = new Map();
|
||||
|
||||
// Agent HTTPS qui ignore les certificats auto-signés
|
||||
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
||||
|
||||
// Au démarrage : marquer tous les déploiements "running" orphelins comme "failed"
|
||||
// (ils ont été interrompus lors du dernier redémarrage du Dashboard)
|
||||
function cleanupOrphanedDeployments() {
|
||||
const orphans = deploymentLogs.filter((l) => l.status === 'running');
|
||||
if (orphans.length > 0) {
|
||||
console.log(`[healthcheck] Nettoyage de ${orphans.length} déploiement(s) orphelin(s) au démarrage`);
|
||||
orphans.forEach((l) => {
|
||||
l.status = 'failed';
|
||||
l.message = 'Déploiement interrompu (redémarrage du Dashboard)';
|
||||
l.endedAt = new Date().toISOString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier la santé d'une URL
|
||||
*/
|
||||
async function checkUrl(url) {
|
||||
try {
|
||||
const start = Date.now();
|
||||
const response = await axios.get(url, {
|
||||
timeout: 10000,
|
||||
httpsAgent,
|
||||
validateStatus: (status) => status < 500,
|
||||
});
|
||||
const responseTime = Date.now() - start;
|
||||
return {
|
||||
online: true,
|
||||
statusCode: response.status,
|
||||
responseTime,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
online: false,
|
||||
statusCode: null,
|
||||
responseTime: null,
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier la santé d'une application
|
||||
*/
|
||||
async function checkApp(appConfig) {
|
||||
const result = {
|
||||
id: appConfig.id,
|
||||
name: appConfig.name,
|
||||
description: appConfig.description,
|
||||
urls: appConfig.urls,
|
||||
containerName: appConfig.containerName,
|
||||
lastCheck: new Date().toISOString(),
|
||||
status: 'unknown',
|
||||
container: null,
|
||||
health: {
|
||||
recette: null,
|
||||
prod: null,
|
||||
},
|
||||
};
|
||||
|
||||
// Vérifier le conteneur Docker
|
||||
const containerInfo = await getContainerInfo(appConfig.containerName);
|
||||
result.container = containerInfo;
|
||||
|
||||
// Health check recette
|
||||
if (appConfig.urls.recette) {
|
||||
result.health.recette = await checkUrl(appConfig.urls.recette);
|
||||
}
|
||||
|
||||
// Health check prod
|
||||
if (appConfig.urls.prod) {
|
||||
result.health.prod = await checkUrl(appConfig.urls.prod);
|
||||
}
|
||||
|
||||
// Déterminer le statut global
|
||||
if (!containerInfo || !containerInfo.running) {
|
||||
result.status = 'offline';
|
||||
} else if (result.health.recette && result.health.recette.online) {
|
||||
result.status = 'online';
|
||||
} else if (containerInfo.running) {
|
||||
result.status = 'error';
|
||||
} else {
|
||||
result.status = 'offline';
|
||||
}
|
||||
|
||||
// Stocker le résultat
|
||||
appStatuses.set(appConfig.id, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lancer les health checks pour toutes les apps
|
||||
*/
|
||||
async function checkAllApps() {
|
||||
const results = [];
|
||||
for (const app of config.apps) {
|
||||
const result = await checkApp(app);
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajouter un log de déploiement (retourne l'entrée créée avec son id)
|
||||
*/
|
||||
function addDeploymentLog(appId, log) {
|
||||
const entry = {
|
||||
id: Date.now().toString(),
|
||||
appId,
|
||||
timestamp: new Date().toISOString(),
|
||||
...log,
|
||||
};
|
||||
deploymentLogs.unshift(entry);
|
||||
if (deploymentLogs.length > MAX_DEPLOYMENT_LOGS) {
|
||||
deploymentLogs.pop();
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour un log de déploiement existant par son id
|
||||
* Permet de passer de "running" à "success" ou "failed" sans créer une nouvelle entrée
|
||||
*/
|
||||
function updateDeploymentLog(entryId, updates) {
|
||||
const entry = deploymentLogs.find((l) => l.id === entryId);
|
||||
if (entry) {
|
||||
Object.assign(entry, updates, { updatedAt: new Date().toISOString() });
|
||||
return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les logs de déploiement
|
||||
*/
|
||||
function getDeploymentLogs(appId = null) {
|
||||
if (appId) {
|
||||
return deploymentLogs.filter((l) => l.appId === appId);
|
||||
}
|
||||
return deploymentLogs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer le statut d'une app
|
||||
*/
|
||||
function getAppStatus(appId) {
|
||||
return appStatuses.get(appId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer tous les statuts
|
||||
*/
|
||||
function getAllStatuses() {
|
||||
return Array.from(appStatuses.values());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkUrl,
|
||||
checkApp,
|
||||
checkAllApps,
|
||||
addDeploymentLog,
|
||||
updateDeploymentLog,
|
||||
getDeploymentLogs,
|
||||
getAppStatus,
|
||||
getAllStatuses,
|
||||
cleanupOrphanedDeployments,
|
||||
appStatuses,
|
||||
giteaCommits,
|
||||
};
|
||||
136
backend/src/index.js
Normal file
136
backend/src/index.js
Normal file
@@ -0,0 +1,136 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const morgan = require('morgan');
|
||||
const path = require('path');
|
||||
const cron = require('node-cron');
|
||||
const http = require('http');
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const config = require('./config');
|
||||
const { initAuth } = require('./auth');
|
||||
const routes = require('./routes');
|
||||
const webhookRoutes = require('./webhook');
|
||||
const { checkAllApps, getAllStatuses } = require('./healthcheck');
|
||||
const { getCommits } = require('./gitea');
|
||||
const { initDynamicApps } = require('./app-creator');
|
||||
const { initSSHWebSocket } = require('./ssh');
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
// WebSocket server pour les mises à jour en temps réel
|
||||
const wss = new WebSocket.Server({ server, path: '/ws' });
|
||||
initSSHWebSocket(server);
|
||||
|
||||
// Middleware
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: false,
|
||||
}));
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||
app.use(cookieParser());
|
||||
app.use(morgan('combined'));
|
||||
|
||||
// Route webhook montée en premier avec raw body pour la vérification HMAC
|
||||
app.use('/api/webhook', express.raw({ type: 'application/json', limit: '10mb' }), (req, res, next) => {
|
||||
if (req.body && Buffer.isBuffer(req.body)) {
|
||||
req.rawBody = req.body;
|
||||
try { req.body = JSON.parse(req.body.toString()); } catch(e) { req.body = {}; }
|
||||
}
|
||||
next();
|
||||
}, webhookRoutes);
|
||||
// API routes
|
||||
app.use('/api', routes);
|
||||
|
||||
// Servir le frontend en production
|
||||
const frontendPath = path.join(__dirname, '../../frontend/dist');
|
||||
app.use(express.static(frontendPath));
|
||||
|
||||
// SPA fallback
|
||||
app.get('*', (req, res) => {
|
||||
if (!req.path.startsWith('/api') && !req.path.startsWith('/ws')) {
|
||||
res.sendFile(path.join(frontendPath, 'index.html'));
|
||||
}
|
||||
});
|
||||
|
||||
// Broadcast WebSocket message
|
||||
global.wsBroadcast = broadcast;
|
||||
function broadcast(data) {
|
||||
const message = JSON.stringify(data);
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Health check périodique
|
||||
async function runHealthChecks() {
|
||||
try {
|
||||
const results = await checkAllApps();
|
||||
broadcast({ type: 'health_update', data: results });
|
||||
|
||||
// Vérifier les nouveaux commits Gitea
|
||||
for (const appConfig of config.apps) {
|
||||
try {
|
||||
const commits = await getCommits(appConfig.giteaOwner, appConfig.giteaRepo, 5);
|
||||
if (commits.length > 0) {
|
||||
broadcast({
|
||||
type: 'gitea_commits',
|
||||
data: { appId: appConfig.id, commits },
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs Gitea
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur health check:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Démarrage
|
||||
async function start() {
|
||||
await initAuth();
|
||||
|
||||
// Charger les applications dynamiques
|
||||
initDynamicApps();
|
||||
|
||||
// Premier health check
|
||||
setTimeout(runHealthChecks, 2000);
|
||||
|
||||
// Health check toutes les 30 secondes
|
||||
setInterval(runHealthChecks, config.healthCheckInterval);
|
||||
|
||||
server.listen(config.port, '0.0.0.0', () => {
|
||||
console.log(`Manus Dashboard Backend démarré sur le port ${config.port}`);
|
||||
console.log(`Frontend servi depuis: ${frontendPath}`);
|
||||
});
|
||||
}
|
||||
|
||||
// WebSocket connection handler
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('Nouvelle connexion WebSocket');
|
||||
|
||||
// Envoyer les statuts actuels
|
||||
const statuses = getAllStatuses();
|
||||
if (statuses.length > 0) {
|
||||
ws.send(JSON.stringify({ type: 'health_update', data: statuses }));
|
||||
}
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('Connexion WebSocket fermée');
|
||||
});
|
||||
});
|
||||
|
||||
start().catch((err) => {
|
||||
console.error('Erreur au démarrage:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
601
backend/src/routes.js
Normal file
601
backend/src/routes.js
Normal file
@@ -0,0 +1,601 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { authenticate, authMiddleware } = require('./auth');
|
||||
const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker');
|
||||
const { getRepo, getCommits, getBranches, listRepos } = require('./gitea');
|
||||
const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck');
|
||||
const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator');
|
||||
const config = require('./config');
|
||||
|
||||
// Nettoyer les déploiements orphelins au démarrage (entrées "running" restantes d'un crash précédent)
|
||||
cleanupOrphanedDeployments();
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Configuration multer pour l'upload de fichiers ZIP
|
||||
const uploadDir = '/tmp/dashboard-uploads';
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
dest: uploadDir,
|
||||
limits: { fileSize: 500 * 1024 * 1024 }, // 500MB max
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype === 'application/zip' ||
|
||||
file.mimetype === 'application/x-zip-compressed' ||
|
||||
file.mimetype === 'application/octet-stream' ||
|
||||
file.originalname.endsWith('.zip')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Seuls les fichiers ZIP sont acceptés'), false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ============ AUTH ROUTES ============
|
||||
|
||||
router.post('/auth/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' });
|
||||
}
|
||||
const token = await authenticate(username, password);
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Identifiants invalides' });
|
||||
}
|
||||
// Set cookie
|
||||
res.cookie('dashboard_token', token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24h
|
||||
});
|
||||
res.json({ token, username });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/auth/logout', (req, res) => {
|
||||
res.clearCookie('dashboard_token');
|
||||
res.json({ message: 'Déconnecté' });
|
||||
});
|
||||
|
||||
router.get('/auth/me', authMiddleware, (req, res) => {
|
||||
res.json({ username: req.user.username, role: req.user.role });
|
||||
});
|
||||
|
||||
// ============ APPS ROUTES ============
|
||||
|
||||
// Liste des applications avec leur statut
|
||||
router.get('/apps', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const statuses = getAllStatuses();
|
||||
if (statuses.length === 0) {
|
||||
// Première requête, lancer un check
|
||||
const results = await checkAllApps();
|
||||
return res.json(results);
|
||||
}
|
||||
res.json(statuses);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Détail d'une application
|
||||
router.get('/apps/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const appConfig = config.apps.find((a) => a.id === req.params.id);
|
||||
if (!appConfig) {
|
||||
return res.status(404).json({ error: 'Application non trouvée' });
|
||||
}
|
||||
|
||||
const status = getAppStatus(appConfig.id);
|
||||
const containerInfo = await getContainerInfo(appConfig.containerName);
|
||||
|
||||
// Récupérer les infos Gitea
|
||||
let giteaInfo = null;
|
||||
let commits = [];
|
||||
try {
|
||||
giteaInfo = await getRepo(appConfig.giteaOwner, appConfig.giteaRepo);
|
||||
commits = await getCommits(appConfig.giteaOwner, appConfig.giteaRepo, 10);
|
||||
} catch (e) {
|
||||
// Gitea peut ne pas être disponible
|
||||
}
|
||||
|
||||
res.json({
|
||||
...status,
|
||||
container: containerInfo,
|
||||
gitea: giteaInfo
|
||||
? {
|
||||
fullName: giteaInfo.full_name,
|
||||
description: giteaInfo.description,
|
||||
defaultBranch: giteaInfo.default_branch,
|
||||
updatedAt: giteaInfo.updated_at,
|
||||
cloneUrl: giteaInfo.clone_url,
|
||||
htmlUrl: giteaInfo.html_url,
|
||||
}
|
||||
: null,
|
||||
commits,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Forcer un health check
|
||||
router.post('/apps/:id/check', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const results = await checkAllApps();
|
||||
const result = results.find((r) => r.id === req.params.id);
|
||||
res.json(result || { error: 'Application non trouvée' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Verrou anti-concurrence par application
|
||||
const deployLocks = new Map();
|
||||
const DEPLOY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes max
|
||||
|
||||
// Nettoyage automatique des verrous bloqués toutes les minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [appId, lockTime] of deployLocks.entries()) {
|
||||
if (now - lockTime > DEPLOY_TIMEOUT_MS) {
|
||||
deployLocks.delete(appId);
|
||||
const logs = getDeploymentLogs(appId);
|
||||
logs.filter(l => l.status === 'running').forEach(l => {
|
||||
l.status = 'failed';
|
||||
l.message = 'Déploiement interrompu automatiquement (timeout 10 min)';
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 60 * 1000);
|
||||
|
||||
// Redéployer une application
|
||||
router.post('/apps/:id/deploy', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const appConfig = config.apps.find((a) => a.id === req.params.id);
|
||||
if (!appConfig) {
|
||||
return res.status(404).json({ error: 'Application non trouvée' });
|
||||
}
|
||||
|
||||
// Vérifier si un déploiement est déjà en cours pour cette app
|
||||
if (deployLocks.has(appConfig.id)) {
|
||||
const lockAge = Math.round((Date.now() - deployLocks.get(appConfig.id)) / 1000);
|
||||
return res.status(409).json({
|
||||
error: `Un déploiement est déjà en cours pour ${appConfig.name} (depuis ${lockAge}s)`,
|
||||
status: 'running'
|
||||
});
|
||||
}
|
||||
|
||||
// Poser le verrou
|
||||
deployLocks.set(appConfig.id, Date.now());
|
||||
|
||||
// Ajouter un log de début et récupérer son id pour le mettre à jour plus tard
|
||||
const deployEntry = addDeploymentLog(appConfig.id, {
|
||||
status: 'running',
|
||||
message: `Déploiement de ${appConfig.name} en cours...`,
|
||||
logs: '',
|
||||
user: req.user.username,
|
||||
});
|
||||
|
||||
// Lancer le redéploiement en arrière-plan
|
||||
res.json({ message: `Redéploiement de ${appConfig.name} lancé`, status: 'running' });
|
||||
|
||||
// Timeout de sécurité : si le déploiement dépasse 10 min, on le marque failed
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
if (deployLocks.has(appConfig.id)) {
|
||||
deployLocks.delete(appConfig.id);
|
||||
updateDeploymentLog(deployEntry.id, {
|
||||
status: 'failed',
|
||||
message: `Déploiement de ${appConfig.name} interrompu (timeout 10 min)`,
|
||||
});
|
||||
}
|
||||
}, DEPLOY_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
// Exécuter le redéploiement
|
||||
const result = await redeployApp(appConfig);
|
||||
clearTimeout(timeoutHandle);
|
||||
|
||||
// Mettre à jour l'entrée existante (pas de doublon "running" + "success")
|
||||
updateDeploymentLog(deployEntry.id, {
|
||||
status: result.success ? 'success' : 'failed',
|
||||
message: result.success
|
||||
? `Redéploiement de ${appConfig.name} réussi en ${result.duration}s`
|
||||
: `Redéploiement de ${appConfig.name} échoué`,
|
||||
logs: result.logs,
|
||||
duration: result.duration,
|
||||
});
|
||||
|
||||
// Relancer un health check après le déploiement
|
||||
setTimeout(() => checkAllApps(), 5000);
|
||||
} finally {
|
||||
// Toujours libérer le verrou
|
||||
deployLocks.delete(appConfig.id);
|
||||
}
|
||||
} catch (err) {
|
||||
deployLocks.delete(req.params.id);
|
||||
addDeploymentLog(req.params.id, {
|
||||
status: 'failed',
|
||||
message: `Erreur: ${err.message}`,
|
||||
logs: '',
|
||||
user: req.user.username,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Logs d'un conteneur
|
||||
router.get('/apps/:id/logs', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const appConfig = config.apps.find((a) => a.id === req.params.id);
|
||||
if (!appConfig) {
|
||||
return res.status(404).json({ error: 'Application non trouvée' });
|
||||
}
|
||||
const tail = parseInt(req.query.tail) || 100;
|
||||
const logs = await getContainerLogs(appConfig.containerName, tail);
|
||||
res.json({ logs });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ CREATE APP ROUTE ============
|
||||
|
||||
// Récupérer les stacks disponibles
|
||||
router.get('/apps-config/stacks', authMiddleware, (req, res) => {
|
||||
res.json(getAvailableStacks());
|
||||
});
|
||||
|
||||
// Créer une nouvelle application
|
||||
router.post('/apps-config/create', authMiddleware, upload.single('zipFile'), async (req, res) => {
|
||||
try {
|
||||
const { name, description, subdomain, stack, port, needsDb, dbType, sourceType, giteaRepoUrl } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!name || !subdomain || !stack || !sourceType) {
|
||||
return res.status(400).json({
|
||||
error: 'Champs requis manquants: name, subdomain, stack, sourceType',
|
||||
});
|
||||
}
|
||||
|
||||
// Vérifier que le subdomain est valide
|
||||
const subdomainRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
||||
if (!subdomainRegex.test(subdomain)) {
|
||||
return res.status(400).json({
|
||||
error: 'Le sous-domaine ne peut contenir que des lettres minuscules, chiffres et tirets',
|
||||
});
|
||||
}
|
||||
|
||||
// Vérifier que l'app n'existe pas déjà
|
||||
const existingApp = config.apps.find((a) => a.id === subdomain);
|
||||
if (existingApp) {
|
||||
return res.status(409).json({
|
||||
error: `Une application avec l'identifiant "${subdomain}" existe déjà`,
|
||||
});
|
||||
}
|
||||
|
||||
// Vérifier le fichier ZIP si sourceType === 'zip'
|
||||
if (sourceType === 'zip' && !req.file) {
|
||||
return res.status(400).json({
|
||||
error: 'Un fichier ZIP est requis pour le type de source "zip"',
|
||||
});
|
||||
}
|
||||
|
||||
// Vérifier l'URL Gitea si sourceType === 'gitea'
|
||||
if (sourceType === 'gitea' && !giteaRepoUrl) {
|
||||
return res.status(400).json({
|
||||
error: 'L\'URL du dépôt Gitea est requise pour le type de source "gitea"',
|
||||
});
|
||||
}
|
||||
|
||||
// Log de début de création et récupérer l'id pour mise à jour ultérieure
|
||||
const logLines = [];
|
||||
const createEntry = addDeploymentLog(subdomain, {
|
||||
status: 'running',
|
||||
message: `Création de l'application ${name} en cours...`,
|
||||
logs: '',
|
||||
user: req.user.username,
|
||||
});
|
||||
|
||||
// Répondre immédiatement
|
||||
res.json({
|
||||
message: `Création de l'application ${name} lancée`,
|
||||
status: 'running',
|
||||
appId: subdomain,
|
||||
});
|
||||
|
||||
// Lancer la création en arrière-plan
|
||||
try {
|
||||
const result = await createApplication(
|
||||
{
|
||||
name,
|
||||
description,
|
||||
subdomain,
|
||||
stack,
|
||||
port: port ? parseInt(port) : null,
|
||||
needsDb: needsDb === 'true' || needsDb === true,
|
||||
dbType: dbType || 'mysql',
|
||||
sourceType,
|
||||
giteaRepoUrl,
|
||||
zipFilePath: req.file ? req.file.path : null,
|
||||
},
|
||||
(line) => logLines.push(line)
|
||||
);
|
||||
|
||||
updateDeploymentLog(createEntry.id, {
|
||||
status: 'success',
|
||||
message: `Application ${name} créée et déployée avec succès`,
|
||||
logs: logLines.join('\n'),
|
||||
});
|
||||
|
||||
// Relancer un health check
|
||||
setTimeout(() => checkAllApps(), 10000);
|
||||
} catch (createErr) {
|
||||
updateDeploymentLog(createEntry.id, {
|
||||
status: 'failed',
|
||||
message: `Création de ${name} échouée: ${createErr.message}`,
|
||||
logs: logLines.join('\n'),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ DEPLOYMENT LOGS ============
|
||||
|
||||
router.get('/deployments', authMiddleware, (req, res) => {
|
||||
const appId = req.query.appId || null;
|
||||
res.json(getDeploymentLogs(appId));
|
||||
});
|
||||
|
||||
// ============ GITEA ROUTES ============
|
||||
|
||||
router.get('/gitea/repos', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const repos = await listRepos();
|
||||
res.json(
|
||||
repos.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
fullName: r.full_name,
|
||||
description: r.description,
|
||||
private: r.private,
|
||||
htmlUrl: r.html_url,
|
||||
cloneUrl: r.clone_url,
|
||||
updatedAt: r.updated_at,
|
||||
language: r.language,
|
||||
size: r.size,
|
||||
}))
|
||||
);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/gitea/repos/:owner/:repo/commits', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const commits = await getCommits(req.params.owner, req.params.repo, 20);
|
||||
res.json(commits);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/gitea/repos/:owner/:repo/branches', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const branches = await getBranches(req.params.owner, req.params.repo);
|
||||
res.json(branches);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ PUBLIC STATUS ROUTE (sans authentification) ============
|
||||
// Utilisé par le portail applicatif pour griser les tuiles des apps arrêtées
|
||||
router.get('/public/status', async (req, res) => {
|
||||
try {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
const statuses = getAllStatuses();
|
||||
const publicStatus = statuses.map((app) => ({
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
status: app.status, // 'online' | 'offline' | 'error' | 'unknown'
|
||||
containerRunning: app.container ? app.container.running : false,
|
||||
}));
|
||||
res.json(publicStatus);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ PUBLIC APPS ROUTE (sans authentification) ============
|
||||
// Utilisé par le portail applicatif pour construire les tuiles dynamiquement
|
||||
// Ne bloque jamais : retourne les apps avec status 'unknown' si les health checks ne sont pas encore disponibles
|
||||
router.get('/public/apps', (req, res) => {
|
||||
try {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
// getAllStatuses() retourne un tableau vide si aucun check n'a encore été effectué
|
||||
// On ne déclenche PAS checkAllApps() ici pour éviter de bloquer la réponse
|
||||
const statuses = getAllStatuses();
|
||||
const publicApps = config.apps
|
||||
.filter((a) => a.category !== 'INFRA') // Exclure les apps infra (portail, dashboard)
|
||||
.map((a) => {
|
||||
const status = statuses.find((s) => s.id === a.id);
|
||||
return {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
category: a.category || 'SANTINOVA',
|
||||
image: a.image || ('images/' + a.id + '.png'),
|
||||
urls: a.urls || {},
|
||||
status: status ? status.status : 'unknown',
|
||||
containerRunning: status && status.container ? status.container.running : false,
|
||||
};
|
||||
});
|
||||
res.json(publicApps);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ DOCKER ROUTES ============
|
||||
|
||||
router.get('/docker/containers', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const containers = await listContainers();
|
||||
res.json(containers);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ SYSTEM INFO ============
|
||||
|
||||
router.get('/system/info', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const containers = await listContainers();
|
||||
res.json({
|
||||
totalApps: config.apps.length,
|
||||
totalContainers: containers.length,
|
||||
runningContainers: containers.filter((c) => c.state === 'running').length,
|
||||
giteaUrl: config.gitea.url,
|
||||
serverTime: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ============ CONTAINER CONTROL (start/stop/restart) ============
|
||||
router.post('/apps/:id/start', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const app = config.apps.find((a) => a.id === req.params.id);
|
||||
if (!app) return res.status(404).json({ error: 'Application non trouvée' });
|
||||
const containerName = app.containerName || app.id;
|
||||
const result = await startContainer(containerName);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/apps/:id/stop', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const app = config.apps.find((a) => a.id === req.params.id);
|
||||
if (!app) return res.status(404).json({ error: 'Application non trouvée' });
|
||||
const containerName = app.containerName || app.id;
|
||||
const result = await stopContainer(containerName);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/apps/:id/restart', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const app = config.apps.find((a) => a.id === req.params.id);
|
||||
if (!app) return res.status(404).json({ error: 'Application non trouvée' });
|
||||
const containerName = app.containerName || app.id;
|
||||
const result = await restartContainer(containerName);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ SERVER METRICS ============
|
||||
router.get('/system/metrics', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const metrics = await getServerMetrics();
|
||||
res.json(metrics);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
// ===== INVENTAIRE DES APPLICATIONS =====
|
||||
const { exec } = require('child_process');
|
||||
const GITEA_RECETTE_INTERNAL = process.env.GITEA_RECETTE_URL || 'http://gitea:3000';
|
||||
const GITEA_RECETTE_PUBLIC = process.env.GITEA_RECETTE_PUBLIC_URL || 'https://git.recette.santinova-soft.org';
|
||||
const GITEA_PROD_EXTERNAL = 'https://git.santinova-soft.org';
|
||||
const GITEA_USER_INV = process.env.GITEA_USERNAME || 'manus-admin';
|
||||
const GITEA_PASS_REC = process.env.GITEA_PASSWORD || 'Itinova69!';
|
||||
const GITEA_PASS_PRD = process.env.GITEA_PASSWORD_PROD || 'ManusGitea2026!';
|
||||
const GITEA_TOKEN_REC = process.env.GITEA_RECETTE_TOKEN || process.env.GITEA_TOKEN || null;
|
||||
|
||||
// INVENTORY_APPS est généré dynamiquement depuis config.apps (lui-même alimenté par la découverte des app.json)
|
||||
function getInventoryApps() {
|
||||
return config.apps.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
repoName: a.giteaRepo || a.id,
|
||||
}));
|
||||
}
|
||||
|
||||
function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) {
|
||||
return new Promise((resolve) => {
|
||||
const authHeader = token
|
||||
? `-H "Authorization: token ${token}"`
|
||||
: `-u "${GITEA_USER_INV}:${pass}"`;
|
||||
const cmd = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}"`;
|
||||
exec(cmd, { timeout: 7000 }, (err, stdout) => {
|
||||
if (err || !stdout) return resolve({ present: false, url: null, version: null });
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
if (!data.id) return resolve({ present: false, url: null, version: null });
|
||||
// Récupérer le dernier commit
|
||||
const cmd2 = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}/commits?limit=1"`;
|
||||
exec(cmd2, { timeout: 7000 }, (err2, stdout2) => {
|
||||
let version = null;
|
||||
try {
|
||||
const commits = JSON.parse(stdout2 || '[]');
|
||||
if (commits.length > 0) {
|
||||
const c = commits[0];
|
||||
const sha = c.sha ? c.sha.substring(0, 7) : null;
|
||||
const date = c.commit && c.commit.author && c.commit.author.date
|
||||
? new Date(c.commit.author.date).toLocaleDateString('fr-FR') : null;
|
||||
version = sha && date ? `${sha} (${date})` : sha;
|
||||
}
|
||||
} catch(e) {}
|
||||
const displayUrl = publicBaseUrl || baseUrl;
|
||||
resolve({ present: true, url: `${displayUrl}/${owner}/${repo}`, version });
|
||||
});
|
||||
} catch(e) {
|
||||
resolve({ present: false, url: null, version: null });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/inventory', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const owner = GITEA_USER_INV;
|
||||
const INVENTORY_APPS = getInventoryApps();
|
||||
const results = await Promise.all(
|
||||
INVENTORY_APPS.map(async (app) => {
|
||||
const [repoRecette, repoProd] = await Promise.all([
|
||||
curlGitea(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_PASS_REC, GITEA_RECETTE_PUBLIC, GITEA_TOKEN_REC),
|
||||
curlGitea(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_PASS_PRD),
|
||||
]);
|
||||
return { ...app, repoRecette, repoProd };
|
||||
})
|
||||
);
|
||||
res.json(results);
|
||||
} catch (err) {
|
||||
console.error('Erreur /inventory:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
;
|
||||
179
backend/src/ssh.js
Normal file
179
backend/src/ssh.js
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Module SSH Terminal - Gestion des connexions SSH via WebSocket
|
||||
* Utilise la bibliothèque ssh2 pour établir des connexions SSH
|
||||
*/
|
||||
const { Client } = require('ssh2');
|
||||
const WebSocket = require('ws');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('./config');
|
||||
|
||||
/**
|
||||
* Initialise le serveur WebSocket SSH sur /ws-ssh
|
||||
* @param {http.Server} server - Le serveur HTTP Express
|
||||
*/
|
||||
function initSSHWebSocket(server) {
|
||||
const wssSsh = new WebSocket.Server({ server, path: '/ws-ssh' });
|
||||
|
||||
wssSsh.on('connection', (ws, req) => {
|
||||
// Vérifier l'authentification via le token dans l'URL
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Token manquant' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
jwt.verify(token, config.jwtSecret);
|
||||
} catch (err) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Token invalide' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Nouvelle connexion WebSocket SSH');
|
||||
|
||||
let sshClient = null;
|
||||
let sshStream = null;
|
||||
let connected = false;
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
|
||||
switch (msg.type) {
|
||||
case 'connect':
|
||||
// Établir la connexion SSH
|
||||
handleConnect(ws, msg, (client, stream) => {
|
||||
sshClient = client;
|
||||
sshStream = stream;
|
||||
connected = true;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'input':
|
||||
// Envoyer des données au terminal SSH
|
||||
if (sshStream && connected) {
|
||||
sshStream.write(msg.data);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'resize':
|
||||
// Redimensionner le terminal
|
||||
if (sshStream && connected) {
|
||||
sshStream.setWindow(msg.rows, msg.cols, 0, 0);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'disconnect':
|
||||
// Fermer la connexion SSH
|
||||
if (sshClient) {
|
||||
sshClient.end();
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Type de message SSH inconnu:', msg.type);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur parsing message SSH:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('Connexion WebSocket SSH fermée');
|
||||
if (sshClient) {
|
||||
sshClient.end();
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('Erreur WebSocket SSH:', err.message);
|
||||
if (sshClient) {
|
||||
sshClient.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log('Serveur WebSocket SSH initialisé sur /ws-ssh');
|
||||
return wssSsh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gère la connexion SSH
|
||||
*/
|
||||
function handleConnect(ws, msg, onConnected) {
|
||||
const { host, port, username, password, privateKey } = msg;
|
||||
|
||||
if (!host || !username) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Hôte et utilisateur requis' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const sshClient = new Client();
|
||||
|
||||
const connConfig = {
|
||||
host: host,
|
||||
port: port || 22,
|
||||
username: username,
|
||||
readyTimeout: 15000,
|
||||
keepaliveInterval: 10000,
|
||||
};
|
||||
|
||||
if (privateKey) {
|
||||
connConfig.privateKey = privateKey;
|
||||
} else if (password) {
|
||||
connConfig.password = password;
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Mot de passe ou clé privée requis' }));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({ type: 'status', message: `Connexion à ${username}@${host}:${port || 22}...` }));
|
||||
|
||||
sshClient.on('ready', () => {
|
||||
ws.send(JSON.stringify({ type: 'connected', message: `Connecté à ${host}` }));
|
||||
|
||||
sshClient.shell({ term: 'xterm-256color', rows: 24, cols: 80 }, (err, stream) => {
|
||||
if (err) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: `Erreur shell: ${err.message}` }));
|
||||
sshClient.end();
|
||||
return;
|
||||
}
|
||||
|
||||
onConnected(sshClient, stream);
|
||||
|
||||
// Transmettre les données du terminal SSH vers le WebSocket
|
||||
stream.on('data', (data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'output', data: data.toString('base64') }));
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'output', data: data.toString('base64') }));
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
ws.send(JSON.stringify({ type: 'disconnected', message: 'Session SSH terminée' }));
|
||||
sshClient.end();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
sshClient.on('error', (err) => {
|
||||
ws.send(JSON.stringify({ type: 'error', message: `Erreur SSH: ${err.message}` }));
|
||||
});
|
||||
|
||||
sshClient.on('end', () => {
|
||||
ws.send(JSON.stringify({ type: 'disconnected', message: 'Connexion SSH fermée' }));
|
||||
});
|
||||
|
||||
sshClient.connect(connConfig);
|
||||
}
|
||||
|
||||
module.exports = { initSSHWebSocket };
|
||||
143
backend/src/webhook.js
Normal file
143
backend/src/webhook.js
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Manus Dashboard - Webhook Handler (CI/CD Gitea)
|
||||
* Route : POST /api/webhook/gitea
|
||||
* Écoute les événements push de Gitea et déclenche le déploiement automatique.
|
||||
*/
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { exec } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '';
|
||||
const APPS_BASE_PATH = process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps';
|
||||
const DEPLOY_SCRIPT = '/opt/manus-deploy/scripts/deploy-app.sh';
|
||||
const LOG_DIR = '/var/log/manus-deploy';
|
||||
|
||||
// Mapping dépôt Gitea -> nom du dossier application sur le serveur
|
||||
const REPO_TO_APP_MAP = {
|
||||
'itinova-contacts': 'itinova-contacts',
|
||||
'itinova-podcasts': 'itinova-podcasts',
|
||||
'veille-reglementaire': 'veille-reglementaire',
|
||||
'itinova-vehicle-exchange': 'itinova-vehicle-exchange',
|
||||
'manus-dashboard': 'manus-dashboard',
|
||||
'itinova-budget-si': 'itinova-budget-si',
|
||||
};
|
||||
|
||||
// Déploiements en cours (évite les doubles déclenchements)
|
||||
const deployingApps = new Set();
|
||||
|
||||
function verifyGiteaSignature(req) {
|
||||
if (!WEBHOOK_SECRET) {
|
||||
console.warn('[Webhook] AVERTISSEMENT: WEBHOOK_SECRET non défini. Validation désactivée.');
|
||||
return true;
|
||||
}
|
||||
const signature = req.headers['x-gitea-signature'] || req.headers['x-hub-signature-256'] || '';
|
||||
console.log('[Webhook DEBUG] All headers:', JSON.stringify(Object.keys(req.headers)));
|
||||
console.log('[Webhook DEBUG] x-gitea-signature:', req.headers['x-gitea-signature'] || 'ABSENT');
|
||||
console.log('[Webhook DEBUG] rawBody available:', !!req.rawBody, 'length:', req.rawBody ? req.rawBody.length : 0);
|
||||
// Utiliser rawBody si disponible (préservé avant express.json), sinon re-sérialiser
|
||||
const bodyStr = req.rawBody ? req.rawBody.toString() : JSON.stringify(req.body);
|
||||
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET).update(bodyStr).digest('hex');
|
||||
const expected = `sha256=${hmac}`;
|
||||
const received = signature.startsWith('sha256=') ? signature : `sha256=${signature}`;
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runDeploy(appName, branch, commitHash, committer, broadcast) {
|
||||
if (deployingApps.has(appName)) {
|
||||
console.log(`[Webhook] Déploiement déjà en cours pour ${appName}. Ignoré.`);
|
||||
return;
|
||||
}
|
||||
deployingApps.add(appName);
|
||||
console.log(`[Webhook] Déclenchement déploiement: ${appName} (branch: ${branch}, commit: ${commitHash})`);
|
||||
|
||||
if (broadcast) {
|
||||
broadcast({ type: 'deploy_started', data: { app: appName, commit: commitHash, branch, committer } });
|
||||
}
|
||||
|
||||
const env = { ...process.env, APPS_BASE_PATH };
|
||||
const cmd = `bash ${DEPLOY_SCRIPT} ${appName} recette`;
|
||||
let output = '';
|
||||
|
||||
const child = exec(cmd, { env, timeout: 600000 });
|
||||
child.stdout.on('data', (d) => { output += d; process.stdout.write(`[${appName}] ${d}`); });
|
||||
child.stderr.on('data', (d) => { output += d; process.stderr.write(`[${appName}] ERR: ${d}`); });
|
||||
|
||||
child.on('close', (code) => {
|
||||
deployingApps.delete(appName);
|
||||
const status = code === 0 ? 'success' : 'failed';
|
||||
console.log(`[Webhook] Déploiement ${status} pour ${appName} (exit: ${code})`);
|
||||
if (broadcast) {
|
||||
broadcast({ type: 'deploy_finished', data: { app: appName, status, exitCode: code } });
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(LOG_DIR, `${appName}-last-deploy.json`), JSON.stringify({
|
||||
app: appName, branch, commit: commitHash, committer, status, exitCode: code,
|
||||
timestamp: new Date().toISOString(), output: output.slice(-3000),
|
||||
}, null, 2));
|
||||
} catch (e) { console.error('[Webhook] Erreur sauvegarde statut:', e.message); }
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/webhook/gitea
|
||||
router.post('/gitea', (req, res) => {
|
||||
if (!verifyGiteaSignature(req)) {
|
||||
console.warn('[Webhook] Signature invalide. Requête rejetée.');
|
||||
return res.status(401).json({ error: 'Signature invalide' });
|
||||
}
|
||||
const event = req.headers['x-gitea-event'] || 'unknown';
|
||||
if (event !== 'push') {
|
||||
return res.status(200).json({ message: `Événement ${event} ignoré` });
|
||||
}
|
||||
const payload = req.body;
|
||||
const repoName = payload?.repository?.name || '';
|
||||
const branch = (payload?.ref || '').replace('refs/heads/', '');
|
||||
const commitHash = (payload?.after || '').slice(0, 8) || 'unknown';
|
||||
const committer = payload?.pusher?.login || payload?.pusher?.name || 'unknown';
|
||||
|
||||
console.log(`[Webhook] Push: repo=${repoName}, branch=${branch}, commit=${commitHash}, by=${committer}`);
|
||||
|
||||
const appName = REPO_TO_APP_MAP[repoName];
|
||||
if (!appName) return res.status(200).json({ message: `Dépôt ${repoName} non configuré` });
|
||||
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 });
|
||||
|
||||
// Récupérer la fonction broadcast si disponible globalement
|
||||
const broadcastFn = global.wsBroadcast || null;
|
||||
setImmediate(() => runDeploy(appName, branch, commitHash, committer, broadcastFn));
|
||||
});
|
||||
|
||||
// GET /api/webhook/status/:appName
|
||||
router.get('/status/:appName', (req, res) => {
|
||||
const { appName } = req.params;
|
||||
const statusFile = path.join(LOG_DIR, `${appName}-last-deploy.json`);
|
||||
if (!fs.existsSync(statusFile)) return res.status(404).json({ error: 'Aucun déploiement enregistré' });
|
||||
try {
|
||||
const status = JSON.parse(fs.readFileSync(statusFile, 'utf8'));
|
||||
res.json({ ...status, isDeploying: deployingApps.has(appName) });
|
||||
} catch { res.status(500).json({ error: 'Erreur lecture statut' }); }
|
||||
});
|
||||
|
||||
// GET /api/webhook/status
|
||||
router.get('/status', (req, res) => {
|
||||
try {
|
||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
const files = fs.readdirSync(LOG_DIR).filter(f => f.endsWith('-last-deploy.json'));
|
||||
const statuses = files.map(f => {
|
||||
try { const d = JSON.parse(fs.readFileSync(path.join(LOG_DIR, f), 'utf8')); return { ...d, isDeploying: deployingApps.has(d.app) }; }
|
||||
catch { return null; }
|
||||
}).filter(Boolean);
|
||||
res.json({ deployments: statuses, currentlyDeploying: [...deployingApps] });
|
||||
} catch { res.status(500).json({ error: 'Erreur lecture statuts' }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user