180 lines
6.6 KiB
Python
180 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
VPS Monitor Agent — à déployer sur chaque VPS.
|
|
Expose une API REST utilisée par le backend central pour interroger les conteneurs Docker.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import time
|
|
from datetime import datetime, timezone
|
|
|
|
import docker
|
|
import psutil
|
|
from docker.errors import DockerException, NotFound
|
|
from fastapi import Depends, FastAPI, HTTPException, Security
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.security import APIKeyHeader
|
|
|
|
# ─── Config ───────────────────────────────────────────────────────────────────
|
|
|
|
API_KEY = os.getenv("AGENT_API_KEY", "changeme-please")
|
|
AGENT_PORT = int(os.getenv("AGENT_PORT", "8001"))
|
|
|
|
# ─── App ──────────────────────────────────────────────────────────────────────
|
|
|
|
app = FastAPI(title="VPS Monitor Agent", version="1.0.0", docs_url=None, redoc_url=None)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_methods=["GET", "POST"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=True)
|
|
|
|
|
|
def require_api_key(key: str = Security(api_key_header)) -> None:
|
|
if key != API_KEY:
|
|
raise HTTPException(status_code=403, detail="Clé API invalide")
|
|
|
|
|
|
def get_docker_client():
|
|
try:
|
|
return docker.from_env()
|
|
except DockerException as e:
|
|
raise HTTPException(status_code=503, detail=f"Docker inaccessible : {e}")
|
|
|
|
|
|
# ─── Routes ───────────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
"""Vérification de disponibilité — sans authentification."""
|
|
return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()}
|
|
|
|
|
|
@app.get("/containers")
|
|
def list_containers(_: None = Depends(require_api_key)):
|
|
"""Retourne tous les conteneurs (actifs et arrêtés)."""
|
|
client = get_docker_client()
|
|
result = []
|
|
for c in client.containers.list(all=True):
|
|
image_tag = c.image.tags[0] if c.image.tags else c.image.short_id
|
|
health_state = c.attrs.get("State", {}).get("Health", {})
|
|
health = health_state.get("Status", "none") if health_state else "none"
|
|
result.append({
|
|
"id": c.short_id,
|
|
"name": c.name.lstrip("/"),
|
|
"status": c.status,
|
|
"health": health,
|
|
"image": image_tag,
|
|
"created": c.attrs.get("Created", ""),
|
|
"compose_project": c.labels.get("com.docker.compose.project", ""),
|
|
"compose_service": c.labels.get("com.docker.compose.service", ""),
|
|
"compose_working_dir": c.labels.get("com.docker.compose.project.working_dir", ""),
|
|
"ports": {
|
|
host: [{"HostIp": b["HostIp"], "HostPort": b["HostPort"]} for b in bindings]
|
|
for host, bindings in (c.ports or {}).items()
|
|
if bindings
|
|
},
|
|
})
|
|
return sorted(result, key=lambda x: x["name"])
|
|
|
|
|
|
@app.get("/containers/{container_id}/logs")
|
|
def get_logs(container_id: str, lines: int = 100, _: None = Depends(require_api_key)):
|
|
"""Retourne les N dernières lignes de logs d'un conteneur."""
|
|
client = get_docker_client()
|
|
try:
|
|
c = client.containers.get(container_id)
|
|
raw = c.logs(tail=lines, timestamps=True)
|
|
return {"logs": raw.decode("utf-8", errors="replace")}
|
|
except NotFound:
|
|
raise HTTPException(status_code=404, detail="Conteneur introuvable")
|
|
|
|
|
|
@app.post("/containers/{container_id}/action")
|
|
def container_action(container_id: str, action: str, _: None = Depends(require_api_key)):
|
|
"""Effectue une action sur un conteneur : start, stop, restart."""
|
|
if action not in ("start", "stop", "restart"):
|
|
raise HTTPException(status_code=400, detail=f"Action invalide : {action}")
|
|
client = get_docker_client()
|
|
try:
|
|
c = client.containers.get(container_id)
|
|
getattr(c, action)()
|
|
return {"status": "ok", "action": action, "container": container_id}
|
|
except NotFound:
|
|
raise HTTPException(status_code=404, detail="Conteneur introuvable")
|
|
|
|
|
|
@app.get("/system")
|
|
def system_info(_: None = Depends(require_api_key)):
|
|
"""Retourne les informations système : CPU, RAM et bande passante."""
|
|
cpu_percent = psutil.cpu_percent(interval=0.5)
|
|
mem = psutil.virtual_memory()
|
|
|
|
net1 = psutil.net_io_counters()
|
|
time.sleep(0.5)
|
|
net2 = psutil.net_io_counters()
|
|
net_sent_per_sec = (net2.bytes_sent - net1.bytes_sent) * 2
|
|
net_recv_per_sec = (net2.bytes_recv - net1.bytes_recv) * 2
|
|
|
|
return {
|
|
"cpu_percent": cpu_percent,
|
|
"ram_used": mem.used,
|
|
"ram_total": mem.total,
|
|
"ram_percent": mem.percent,
|
|
"net_sent_per_sec": net_sent_per_sec,
|
|
"net_recv_per_sec": net_recv_per_sec,
|
|
}
|
|
|
|
|
|
@app.post("/compose/update")
|
|
def compose_update(project: str, _: None = Depends(require_api_key)):
|
|
"""Pull les nouvelles images et recrée les conteneurs d'un projet compose."""
|
|
client = get_docker_client()
|
|
|
|
working_dir = None
|
|
for c in client.containers.list(all=True):
|
|
if c.labels.get("com.docker.compose.project") == project:
|
|
working_dir = c.labels.get("com.docker.compose.project.working_dir")
|
|
if working_dir:
|
|
break
|
|
|
|
if not working_dir:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Projet compose '{project}' introuvable ou sans répertoire de travail",
|
|
)
|
|
|
|
output = ""
|
|
|
|
pull = subprocess.run(
|
|
["docker", "compose", "pull"],
|
|
cwd=working_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=300,
|
|
)
|
|
output += pull.stdout + pull.stderr
|
|
|
|
up = subprocess.run(
|
|
["docker", "compose", "up", "-d", "--remove-orphans"],
|
|
cwd=working_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
output += up.stdout + up.stderr
|
|
|
|
return {"output": output, "project": project, "working_dir": working_dir}
|
|
|
|
|
|
# ─── Entrée ───────────────────────────────────────────────────────────────────
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="0.0.0.0", port=AGENT_PORT)
|