feat: add applications management to the agent and backend
All checks were successful
Build and Push Docker Images / docker (push) Successful in 25s

- Updated agent to version 1.4.0 with new endpoints for managing Docker Compose applications.
- Implemented API requests in the backend for listing, creating, editing, and deploying applications.
- Introduced a new AppsModal component in the frontend for user interaction with applications.
- Added YAML editor for editing Docker Compose files with validation.
- Enhanced VpsCard component to include options for managing applications.
- Updated client API functions to support new application management features.
This commit is contained in:
jeanotx32
2026-08-01 02:11:24 -04:00
parent e277e99155
commit 71c17e3dc1
8 changed files with 978 additions and 13 deletions

View File

@@ -141,6 +141,7 @@ Le fichier `.env` supporte les variables suivantes :
| `AGENT_API_KEY` | *(généré)* | Clé secrète partagée avec le backend |
| `AGENT_PORT` | `8001` | Port TCP d'écoute |
| `AGENT_MAX_EXEC_SESSIONS` | `10` | Nombre de terminaux ouverts simultanément (voir ci-dessous) |
| `AGENT_APPS_DIR` | `/home` | Racine des applications compose (voir ci-dessous) |
Pour modifier la configuration sans réinstaller :
@@ -186,6 +187,40 @@ proxy_read_timeout 3600s;
---
## Applications compose (agent 1.4.0+)
L'agent expose les fichiers compose rangés selon la convention
`/home/<application>/compose.yaml` (les noms `compose.yml`, `docker-compose.yml`
et `docker-compose.yaml` sont également reconnus). L'interface web permet de les
lister, les éditer, en créer de nouveaux et déployer.
| Route agent | Effet |
|-------------|-------|
| `GET /apps` | Liste les dossiers de `/home` contenant un fichier compose, avec le nombre de conteneurs actifs |
| `GET /apps/{nom}/compose` | Contenu du fichier |
| `PUT /apps/{nom}/compose` | Écrit le fichier (`deploy: true` enchaîne sur un déploiement) |
| `POST /apps` | Crée `/home/<nom>/` et son fichier compose |
| `POST /apps/{nom}/up` | `docker compose pull` puis `up -d` dans le dossier |
Garde-fous appliqués :
- **Validation avant écriture.** Le contenu est d'abord vérifié par
`docker compose config` sur un fichier temporaire, dans le dossier de
l'application pour que `.env` et chemins relatifs soient résolus comme au
déploiement. Un fichier invalide est refusé et **rien n'est écrit**.
- **Sauvegarde.** L'ancienne version est copiée en `<fichier>.bak` avant chaque
écriture.
- **Périmètre.** Le nom d'application est restreint à
`[A-Za-z0-9][A-Za-z0-9._-]*` et le chemin résolu doit rester directement sous
`AGENT_APPS_DIR` — les `..` et les liens symboliques sortants sont rejetés.
- **Taille** limitée à 512 kB par fichier.
- **Réservé aux administrateurs**, comme le terminal.
Si vos applications ne sont pas sous `/home`, changez `AGENT_APPS_DIR` dans
`/opt/vps-monitor-agent/.env` puis redémarrez le service.
---
## Dépôt source
[https://git.jeanbonapp.com/jeanbon/ScriptVPS](https://git.jeanbonapp.com/jeanbon/ScriptVPS)

View File

@@ -7,11 +7,14 @@ Expose une API REST utilisée par le backend central pour interroger les contene
import asyncio
import json
import os
import re
import shutil
import socket as socket_module
import subprocess
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
import docker
import psutil
@@ -19,10 +22,11 @@ from docker.errors import DockerException, NotFound
from fastapi import Depends, FastAPI, HTTPException, Security, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import APIKeyHeader
from pydantic import BaseModel
# ─── Config ───────────────────────────────────────────────────────────────────
AGENT_VERSION = "1.3.0"
AGENT_VERSION = "1.4.0"
REPO_BASE = os.getenv("AGENT_REPO_BASE", "https://git.jeanbonapp.com/jeanbon/ScriptVPS/raw/branch/main")
INSTALL_DIR = os.getenv("AGENT_INSTALL_DIR", "/opt/vps-monitor-agent")
@@ -37,7 +41,7 @@ app = FastAPI(title="VPS Monitor Agent", version="1.0.0", docs_url=None, redoc_u
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET", "POST"],
allow_methods=["GET", "POST", "PUT"],
allow_headers=["*"],
)
@@ -406,6 +410,256 @@ def compose_update(project: str, _: None = Depends(require_api_key)):
return {"output": output, "project": project, "working_dir": working_dir}
# ─── Applications compose ────────────────────────────────────────────────────
#
# Convention : une application = un dossier sous /home contenant son fichier
# compose, soit /home/<application>/compose.yaml (ou l'un des autres noms
# reconnus par Docker).
APPS_DIR = Path(os.getenv("AGENT_APPS_DIR", "/home"))
_COMPOSE_NAMES = ("compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml")
_APP_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
_MAX_COMPOSE_BYTES = 512 * 1024
class ComposeWriteRequest(BaseModel):
content: str
deploy: bool = False
class AppCreateRequest(BaseModel):
name: str
content: str
filename: str = "compose.yaml"
deploy: bool = False
def _resolve_app_dir(name: str) -> Path:
"""Chemin du dossier d'une application, en refusant tout ce qui sort d'APPS_DIR."""
if not _APP_NAME_RE.match(name or ""):
raise HTTPException(status_code=400, detail="Nom d'application invalide")
base = APPS_DIR.resolve()
path = (base / name).resolve()
# Le nom ne contient ni « / » ni « .. » (regex) ; ce contrôle attrape en plus
# les liens symboliques qui pointeraient hors de /home.
if path.parent != base:
raise HTTPException(status_code=400, detail="Chemin d'application invalide")
return path
def _find_compose_file(app_dir: Path) -> Path | None:
for candidate in _COMPOSE_NAMES:
path = app_dir / candidate
if path.is_file():
return path
return None
def _validate_compose(app_dir: Path, content: str, display_name: str) -> tuple[bool, str]:
"""Valide le YAML avec `docker compose config` avant d'écrire quoi que ce soit.
La validation se fait sur un fichier temporaire dans le dossier de
l'application, pour que les chemins relatifs et le `.env` soient résolus
exactement comme au déploiement.
"""
tmp = app_dir / f".compose-check-{os.getpid()}.yaml"
try:
tmp.write_text(content, encoding="utf-8")
proc = subprocess.run(
["docker", "compose", "-f", str(tmp), "config", "-q"],
cwd=app_dir, capture_output=True, text=True, timeout=30,
)
message = (proc.stderr or proc.stdout).strip().replace(tmp.name, display_name)
return proc.returncode == 0, message
except FileNotFoundError:
return True, "" # docker compose absent : on n'empêche pas l'enregistrement
except subprocess.TimeoutExpired:
return False, "La validation `docker compose config` a expiré."
except OSError as e:
raise HTTPException(status_code=500, detail=f"Écriture impossible : {e}")
finally:
tmp.unlink(missing_ok=True)
def _compose_up(app_dir: Path) -> str:
output = ""
for cmd, timeout in ((["docker", "compose", "pull"], 600),
(["docker", "compose", "up", "-d", "--remove-orphans"], 300)):
proc = subprocess.run(cmd, cwd=app_dir, capture_output=True, text=True, timeout=timeout)
output += proc.stdout + proc.stderr
return output
def _app_payload(app_dir: Path, compose: Path, running: dict[str, int]) -> dict:
stat = compose.stat()
return {
"name": app_dir.name,
"path": str(app_dir),
"file": compose.name,
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"running_containers": running.get(str(app_dir), 0),
}
def _running_by_working_dir() -> dict[str, int]:
"""Conteneurs démarrés, indexés par répertoire de projet compose."""
counts: dict[str, int] = {}
try:
client = docker.from_env()
for container in client.containers.list():
working_dir = container.labels.get("com.docker.compose.project.working_dir")
if working_dir:
counts[working_dir] = counts.get(working_dir, 0) + 1
except DockerException:
pass
return counts
@app.get("/apps")
def list_apps(_: None = Depends(require_api_key)):
"""Liste les applications : dossiers de /home contenant un fichier compose."""
if not APPS_DIR.is_dir():
return []
running = _running_by_working_dir()
apps = []
try:
entries = sorted(APPS_DIR.iterdir(), key=lambda p: p.name.lower())
except OSError as e:
raise HTTPException(status_code=500, detail=f"Lecture de {APPS_DIR} impossible : {e}")
for entry in entries:
if not entry.is_dir() or entry.name.startswith("."):
continue
compose = _find_compose_file(entry)
if compose:
apps.append(_app_payload(entry, compose, running))
return apps
@app.get("/apps/{name}/compose")
def read_compose(name: str, _: None = Depends(require_api_key)):
"""Retourne le contenu du fichier compose d'une application."""
app_dir = _resolve_app_dir(name)
if not app_dir.is_dir():
raise HTTPException(status_code=404, detail="Application introuvable")
compose = _find_compose_file(app_dir)
if not compose:
raise HTTPException(status_code=404, detail="Aucun fichier compose dans ce dossier")
try:
content = compose.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as e:
raise HTTPException(status_code=500, detail=f"Lecture impossible : {e}")
payload = _app_payload(app_dir, compose, _running_by_working_dir())
payload["content"] = content
return payload
@app.put("/apps/{name}/compose")
def write_compose(name: str, body: ComposeWriteRequest, _: None = Depends(require_api_key)):
"""Écrit le fichier compose, après validation et sauvegarde de l'ancienne version."""
app_dir = _resolve_app_dir(name)
if not app_dir.is_dir():
raise HTTPException(status_code=404, detail="Application introuvable")
if len(body.content.encode("utf-8")) > _MAX_COMPOSE_BYTES:
raise HTTPException(status_code=413, detail="Fichier compose trop volumineux")
target = _find_compose_file(app_dir) or (app_dir / "compose.yaml")
valid, message = _validate_compose(app_dir, body.content, target.name)
if not valid:
raise HTTPException(status_code=400, detail=message or "Fichier compose invalide")
backup = None
try:
if target.exists():
backup = target.with_name(target.name + ".bak")
shutil.copy2(target, backup)
target.write_text(body.content, encoding="utf-8")
except OSError as e:
raise HTTPException(status_code=500, detail=f"Écriture impossible : {e}")
result = {
"status": "ok",
"file": target.name,
"path": str(target),
"backup": backup.name if backup else None,
}
if body.deploy:
try:
result["output"] = _compose_up(app_dir)
except subprocess.TimeoutExpired:
raise HTTPException(status_code=504, detail="Le déploiement a expiré")
return result
@app.post("/apps", status_code=201)
def create_app(body: AppCreateRequest, _: None = Depends(require_api_key)):
"""Crée une application : dossier /home/<nom> et son fichier compose."""
if body.filename not in _COMPOSE_NAMES:
raise HTTPException(
status_code=400,
detail=f"Nom de fichier invalide — attendu : {', '.join(_COMPOSE_NAMES)}",
)
if len(body.content.encode("utf-8")) > _MAX_COMPOSE_BYTES:
raise HTTPException(status_code=413, detail="Fichier compose trop volumineux")
app_dir = _resolve_app_dir(body.name)
if _find_compose_file(app_dir):
raise HTTPException(status_code=409, detail="Cette application existe déjà")
created_dir = not app_dir.exists()
try:
app_dir.mkdir(parents=True, exist_ok=True)
except OSError as e:
raise HTTPException(status_code=500, detail=f"Création du dossier impossible : {e}")
valid, message = _validate_compose(app_dir, body.content, body.filename)
if not valid:
if created_dir:
try:
app_dir.rmdir() # ne laisse pas un dossier vide derrière soi
except OSError:
pass
raise HTTPException(status_code=400, detail=message or "Fichier compose invalide")
target = app_dir / body.filename
try:
target.write_text(body.content, encoding="utf-8")
except OSError as e:
raise HTTPException(status_code=500, detail=f"Écriture impossible : {e}")
result = {
"status": "created",
"name": app_dir.name,
"path": str(target),
"file": target.name,
"created_dir": created_dir,
}
if body.deploy:
try:
result["output"] = _compose_up(app_dir)
except subprocess.TimeoutExpired:
raise HTTPException(status_code=504, detail="Le déploiement a expiré")
return result
@app.post("/apps/{name}/up")
def deploy_app(name: str, _: None = Depends(require_api_key)):
"""Lance `docker compose pull` puis `up -d` dans le dossier de l'application."""
app_dir = _resolve_app_dir(name)
compose = _find_compose_file(app_dir) if app_dir.is_dir() else None
if not compose:
raise HTTPException(status_code=404, detail="Application introuvable")
try:
return {"output": _compose_up(app_dir), "name": app_dir.name, "path": str(app_dir)}
except subprocess.TimeoutExpired:
raise HTTPException(status_code=504, detail="Le déploiement a expiré")
@app.get("/services")
def list_services(_: None = Depends(require_api_key)):
"""Retourne la liste des services systemd (hors Docker) avec leur état."""