feat: add applications management to the agent and backend
All checks were successful
Build and Push Docker Images / docker (push) Successful in 25s
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:
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user