Files
ScriptVPS/src/manage_vps.py
jeanotx32 cf0b3f0acf
Some checks failed
Build and Push Docker Images / docker (push) Failing after 5s
feat: add VPS Monitor backend and frontend services
- Create systemd service for VPS Monitor agent.
- Add FastAPI backend with endpoints for managing VPS configurations and statuses.
- Implement Dockerfile for backend service with required dependencies.
- Create frontend using React with Vite and Tailwind CSS for styling.
- Add API client for communicating with the backend.
- Implement components for displaying VPS information and logs.
- Set up Docker Compose for orchestrating backend and frontend services.
- Add environment configuration files for backend and agent.
- Implement CORS support in the backend for frontend communication.
2026-05-18 22:31:36 -04:00

739 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Gestionnaire d'applications Docker sur VPS Debian.
Navigation au clavier (↑↓ + Entrée), sans dépendance externe.
"""
import os
import subprocess
import sys
import termios
import tty
from pathlib import Path
# ─── Configuration ────────────────────────────────────────────────────────────
APPS_BASE_DIR = "/home"
COMPOSE_FILES = ["compose.yml", "compose.yaml", "docker-compose.yml", "docker-compose.yaml"]
# ─── ANSI ─────────────────────────────────────────────────────────────────────
R = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
CYN = "\033[36m"
YEL = "\033[33m"
GRN = "\033[32m"
RED = "\033[31m"
SEL = "\033[44m\033[97m" # fond bleu + texte blanc
W = 56 # largeur de la boîte
# ─── Gestion du clavier ───────────────────────────────────────────────────────
KEY_UP = "\x1b[A"
KEY_DOWN = "\x1b[B"
KEY_ENTER = ("\r", "\n")
KEY_QUIT = ("q", "Q", "\x1b", "\x03") # q, ESC, Ctrl+C
def getch() -> str:
"""Lit un seul appui clavier sans attendre Entrée."""
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
ch = sys.stdin.read(1)
if ch == "\x1b":
nxt = sys.stdin.read(1)
if nxt == "[":
return "\x1b[" + sys.stdin.read(1)
return "\x1b"
return ch
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def hide_cursor() -> None:
print("\033[?25l", end="", flush=True)
def show_cursor() -> None:
print("\033[?25h", end="", flush=True)
# ─── Rendu ────────────────────────────────────────────────────────────────────
def draw_header(title: str, subtitle: str = "") -> None:
print(f"{CYN}{'' * (W - 2)}{R}")
print(f"{CYN}{R}{BOLD}{title.center(W - 2)}{R}{CYN}{R}")
print(f"{CYN}{'' * (W - 2)}{R}")
if subtitle:
pad = W - 2 - len(subtitle)
print(f"{CYN}{R} {YEL}{subtitle}{R}{' ' * max(0, pad - 1)}{CYN}{R}")
print(f"{CYN}{'' * (W - 2)}{R}")
def draw_footer() -> None:
print(f"{CYN}{'' * (W - 2)}{R}")
hint = f" {YEL}↑↓{R} naviguer {YEL}{R} sélectionner {YEL}q{R} retour"
print(f"{CYN}{R}{hint}")
print(f"{CYN}{'' * (W - 2)}{R}")
def draw_item(index: int, label: str, selected: bool, badge: str = "") -> None:
num = f"[{index + 1}]"
badge_str = f" {GRN}({badge}){R}" if badge else ""
text = f" {num} {label}"
visible_len = len(text) + (len(badge) + 3 if badge else 0)
padding = " " * max(0, W - 4 - visible_len)
if selected:
print(f"{CYN}{R}{SEL} {text}{R}{GRN if badge else ''}{badge_str}{R}{SEL}{padding} {R}{CYN}{R}")
else:
print(f"{CYN}{R} {text}{badge_str}{padding} {CYN}{R}")
# ─── Menu générique ───────────────────────────────────────────────────────────
def navigate_menu(
title: str,
items: list[str],
subtitle: str = "",
badges: list[str] | None = None,
) -> int | None:
"""
Menu interactif à navigation clavier (↑↓ + Entrée).
Retourne l'index sélectionné, ou None si l'utilisateur quitte.
"""
current = 0
badges = badges or [""] * len(items)
while True:
os.system("clear")
hide_cursor()
draw_header(title, subtitle)
for i, item in enumerate(items):
draw_item(i, item, i == current, badges[i])
draw_footer()
key = getch()
if key == KEY_UP:
current = (current - 1) % len(items)
elif key == KEY_DOWN:
current = (current + 1) % len(items)
elif key in KEY_ENTER:
show_cursor()
return current
elif key in KEY_QUIT:
show_cursor()
return None
elif key.isdigit():
idx = int(key) - 1
if 0 <= idx < len(items):
show_cursor()
return idx
def pause(message: str = "Appuyez sur une touche pour continuer…") -> None:
show_cursor()
print(f"\n {DIM}{message}{R}", end="", flush=True)
getch()
# ─── Détection Docker ─────────────────────────────────────────────────────────
def _apps_from_docker_ps() -> list[dict]:
"""Détecte les projets Compose via les labels des conteneurs docker ps -a."""
apps = []
seen_dirs: set[str] = set()
try:
result = subprocess.run(
[
"docker", "ps", "-a",
"--format",
"{{.Label \"com.docker.compose.project\"}}\t"
"{{.Label \"com.docker.compose.project.working_dir\"}}\t"
"{{.Label \"com.docker.compose.project.config_files\"}}",
],
capture_output=True,
text=True,
)
except FileNotFoundError:
return []
for line in result.stdout.splitlines():
parts = line.split("\t")
if len(parts) != 3:
continue
project, working_dir, config_files = (p.strip() for p in parts)
if not project or not working_dir or working_dir in seen_dirs:
continue
compose_file = config_files.split(",")[0].strip() if config_files else ""
if not compose_file or not Path(compose_file).exists():
compose_file = ""
for name in COMPOSE_FILES:
candidate = Path(working_dir) / name
if candidate.exists():
compose_file = str(candidate)
break
if not compose_file:
continue
seen_dirs.add(working_dir)
apps.append({"name": project, "path": working_dir, "compose_file": compose_file})
return apps
def _apps_from_filesystem(exclude_dirs: set) -> list[dict]:
"""Fallback : scan récursif /home/*/ (profondeur 3) pour trouver des fichiers compose."""
apps = []
base = Path(APPS_BASE_DIR)
def search(directory: Path, app_name: str, depth: int) -> None:
if depth == 0:
return
try:
for compose_name in COMPOSE_FILES:
compose_path = directory / compose_name
if compose_path.exists() and str(directory) not in exclude_dirs:
rel = directory.relative_to(base / app_name)
label = app_name if rel == Path(".") else f"{app_name}/{rel}"
apps.append({"name": label, "path": str(directory), "compose_file": str(compose_path)})
exclude_dirs.add(str(directory))
return
for sub in sorted(directory.iterdir()):
if sub.is_dir():
search(sub, app_name, depth - 1)
except PermissionError:
pass
try:
for entry in sorted(base.iterdir()):
if entry.is_dir():
search(entry, entry.name, depth=3)
except PermissionError:
show_cursor()
print(f"Erreur : permission refusée pour lire {APPS_BASE_DIR}")
sys.exit(1)
return apps
def find_docker_apps() -> list[dict]:
"""Détecte les apps Compose via docker ps (labels) puis complète avec le filesystem."""
apps = _apps_from_docker_ps()
seen = {a["path"] for a in apps}
apps += _apps_from_filesystem(seen)
return sorted(apps, key=lambda a: a["name"].lower())
# ─── Actions Docker ───────────────────────────────────────────────────────────
def get_running_containers(app: dict) -> list[str]:
try:
result = subprocess.run(
["docker", "compose", "-f", app["compose_file"], "ps", "--services", "--filter", "status=running"],
capture_output=True, text=True, cwd=app["path"],
)
return [s.strip() for s in result.stdout.splitlines() if s.strip()]
except FileNotFoundError:
return []
def get_all_services(app: dict) -> list[str]:
try:
result = subprocess.run(
["docker", "compose", "-f", app["compose_file"], "config", "--services"],
capture_output=True, text=True, cwd=app["path"],
)
return [s.strip() for s in result.stdout.splitlines() if s.strip()]
except FileNotFoundError:
return []
def get_container_id(app: dict, service: str) -> str | None:
try:
result = subprocess.run(
["docker", "compose", "-f", app["compose_file"], "ps", "-q", service],
capture_output=True, text=True, cwd=app["path"],
)
cid = result.stdout.strip()
return cid if cid else None
except FileNotFoundError:
return None
def action_bash(app: dict, service: str) -> None:
container_id = get_container_id(app, service)
if not container_id:
print(f"\n {RED}Conteneur '{service}' introuvable ou arrêté.{R}")
pause()
return
show_cursor()
os.system("clear")
print(f"{CYN}{'' * W}{R}")
print(f" Shell bash → {BOLD}{app['name']}{R} / {service}")
print(f" {DIM}(tapez 'exit' pour revenir au menu){R}")
print(f"{CYN}{'' * W}{R}\n")
subprocess.run(["docker", "exec", "-it", container_id, "bash"])
def action_logs(app: dict, service: str) -> None:
container_id = get_container_id(app, service)
if not container_id:
print(f"\n {RED}Conteneur '{service}' introuvable ou arrêté.{R}")
pause()
return
show_cursor()
os.system("clear")
print(f"{CYN}{'' * W}{R}")
print(f" Logs → {BOLD}{app['name']}{R} / {service}")
print(f" {DIM}(Ctrl+C pour arrêter){R}")
print(f"{CYN}{'' * W}{R}\n")
try:
subprocess.run(["docker", "logs", "--tail", "100", "-f", container_id])
except KeyboardInterrupt:
print(f"\n {YEL}Suivi interrompu.{R}")
pause()
def action_update(app: dict) -> None:
show_cursor()
os.system("clear")
print(f"{CYN}{'' * W}{R}")
print(f" Mise à jour → {BOLD}{app['name']}{R}")
print(f"{CYN}{'' * W}{R}\n")
print(f" {YEL}→ docker compose pull{R}\n")
result_pull = subprocess.run(
["docker", "compose", "-f", app["compose_file"], "pull"],
cwd=app["path"],
)
if result_pull.returncode != 0:
print(f"\n {RED}Erreur lors du pull.{R}")
pause()
return
print(f"\n {YEL}→ docker compose up -d{R}\n")
result_up = subprocess.run(
["docker", "compose", "-f", app["compose_file"], "up", "-d"],
cwd=app["path"],
)
if result_up.returncode == 0:
print(f"\n {GRN}Mise à jour terminée avec succès.{R}")
else:
print(f"\n {RED}Erreur lors du redémarrage.{R}")
pause()
# ─── Menus ────────────────────────────────────────────────────────────────────
def service_menu(app: dict, action: str) -> None:
"""Sélection d'un service puis exécution de l'action (bash ou logs)."""
services = get_all_services(app)
if not services:
print(f"\n {RED}Aucun service trouvé dans le compose.{R}")
pause()
return
running = set(get_running_containers(app))
badges = ["actif" if s in running else "" for s in services]
label = "Shell bash" if action == "bash" else "Logs"
idx = navigate_menu(
title=" Gestionnaire Docker VPS ",
subtitle=f"{app['name']} {label}",
items=services,
badges=badges,
)
if idx is None:
return
if action == "bash":
action_bash(app, services[idx])
else:
action_logs(app, services[idx])
def app_menu(app: dict) -> None:
"""Menu des actions pour une application."""
actions = [
"Shell bash dans un conteneur",
"Voir les logs d'un conteneur",
"Mettre à jour (pull + up -d)",
]
while True:
running = get_running_containers(app)
if running:
status = f"{len(running)} service(s) actif(s)"
else:
status = "aucun service actif"
idx = navigate_menu(
title=" Gestionnaire Docker VPS ",
subtitle=f"{app['name']} · {status}",
items=actions,
)
if idx is None:
return
elif idx == 0:
service_menu(app, "bash")
elif idx == 1:
service_menu(app, "logs")
elif idx == 2:
action_update(app)
def main() -> None:
if os.geteuid() != 0:
print(f"{YEL}Attention : droits root recommandés (sudo python3 manage_vps.py){R}\n")
try:
while True:
apps = find_docker_apps()
if not apps:
os.system("clear")
print(f"\n {RED}Aucune application Docker trouvée sous {APPS_BASE_DIR}/{R}")
print(f" {DIM}(vérifiez que des fichiers compose.yml existent){R}\n")
sys.exit(0)
idx = navigate_menu(
title=" Gestionnaire Docker VPS ",
subtitle=f"{len(apps)} application(s) détectée(s)",
items=[a["name"] for a in apps],
)
if idx is None:
os.system("clear")
print(f"\n Au revoir !\n")
sys.exit(0)
app_menu(apps[idx])
except KeyboardInterrupt:
show_cursor()
os.system("clear")
print("\n Au revoir !\n")
sys.exit(0)
if __name__ == "__main__":
main()
def _apps_from_docker_ps() -> list[dict]:
"""Détecte les projets Compose via les labels des conteneurs docker ps -a."""
apps = []
seen_dirs = set()
try:
result = subprocess.run(
[
"docker", "ps", "-a",
"--format",
"{{.Label \"com.docker.compose.project\"}}\t"
"{{.Label \"com.docker.compose.project.working_dir\"}}\t"
"{{.Label \"com.docker.compose.project.config_files\"}}",
],
capture_output=True,
text=True,
)
except FileNotFoundError:
return [] # docker non installé
for line in result.stdout.splitlines():
parts = line.split("\t")
if len(parts) != 3:
continue
project, working_dir, config_files = (p.strip() for p in parts)
if not project or not working_dir or working_dir in seen_dirs:
continue
# Prend le premier fichier de config listé
compose_file = config_files.split(",")[0].strip() if config_files else ""
if not compose_file or not Path(compose_file).exists():
# Cherche un fichier compose connu dans le working_dir
compose_file = ""
for name in COMPOSE_FILES:
candidate = Path(working_dir) / name
if candidate.exists():
compose_file = str(candidate)
break
if not compose_file:
continue
seen_dirs.add(working_dir)
apps.append({
"name": project,
"path": working_dir,
"compose_file": compose_file,
})
return apps
def _apps_from_filesystem(exclude_dirs: set) -> list[dict]:
"""Fallback : scanne /home/*/ récursivement (profondeur 3) pour trouver des fichiers compose."""
apps = []
base = Path(APPS_BASE_DIR)
def search(directory: Path, app_name: str, depth: int):
if depth == 0:
return
try:
for compose_name in COMPOSE_FILES:
compose_path = directory / compose_name
if compose_path.exists() and str(directory) not in exclude_dirs:
rel = directory.relative_to(base / app_name)
label = app_name if rel == Path(".") else f"{app_name}/{rel}"
apps.append({
"name": label,
"path": str(directory),
"compose_file": str(compose_path),
})
exclude_dirs.add(str(directory))
return
for sub in sorted(directory.iterdir()):
if sub.is_dir():
search(sub, app_name, depth - 1)
except PermissionError:
pass
try:
for entry in sorted(base.iterdir()):
if entry.is_dir():
search(entry, entry.name, depth=3)
except PermissionError:
print(f" Erreur : permission refusée pour lire {APPS_BASE_DIR}")
sys.exit(1)
return apps
def find_docker_apps() -> list[dict]:
"""Détecte les apps Compose via docker ps (labels) puis complète avec le filesystem."""
apps = _apps_from_docker_ps()
seen_dirs = {a["path"] for a in apps}
apps += _apps_from_filesystem(seen_dirs)
return sorted(apps, key=lambda a: a["name"].lower())
def get_running_containers(app: dict) -> list[str]:
"""Retourne les noms des conteneurs en cours d'exécution pour une app."""
try:
result = subprocess.run(
["docker", "compose", "-f", app["compose_file"], "ps", "--services", "--filter", "status=running"],
capture_output=True,
text=True,
cwd=app["path"],
)
services = [s.strip() for s in result.stdout.splitlines() if s.strip()]
return services
except FileNotFoundError:
return []
def get_container_id(app: dict, service: str) -> str | None:
"""Retourne l'ID du conteneur Docker pour un service donné."""
try:
result = subprocess.run(
["docker", "compose", "-f", app["compose_file"], "ps", "-q", service],
capture_output=True,
text=True,
cwd=app["path"],
)
container_id = result.stdout.strip()
return container_id if container_id else None
except FileNotFoundError:
return None
def print_separator():
print("-" * 50)
def print_header():
print("=" * 50)
print(" Gestionnaire Docker VPS")
print("=" * 50)
def select_from_list(items: list[str], prompt: str) -> int | None:
"""Affiche une liste numérotée et retourne l'index choisi, ou None si annulé."""
for i, item in enumerate(items, 1):
print(f" [{i}] {item}")
print(" [0] Retour")
print()
while True:
try:
choice = input(f"{prompt}: ").strip()
if choice == "0":
return None
idx = int(choice) - 1
if 0 <= idx < len(items):
return idx
print(f" Choix invalide (1-{len(items)} ou 0 pour retour).")
except (ValueError, EOFError):
print(" Entrée invalide.")
def action_bash(app: dict, service: str):
"""Ouvre un shell bash interactif dans le conteneur."""
container_id = get_container_id(app, service)
if not container_id:
print(f" Conteneur '{service}' introuvable ou arrêté.")
input(" Appuyez sur Entrée pour continuer...")
return
print(f"\n Connexion bash → {app['name']} / {service}")
print(" (tapez 'exit' pour revenir au menu)\n")
subprocess.run(["docker", "exec", "-it", container_id, "bash"])
def action_logs(app: dict, service: str):
"""Affiche les logs du conteneur (100 dernières lignes, avec suivi)."""
container_id = get_container_id(app, service)
if not container_id:
print(f" Conteneur '{service}' introuvable ou arrêté.")
input(" Appuyez sur Entrée pour continuer...")
return
print(f"\n Logs → {app['name']} / {service}")
print(" (Ctrl+C pour arrêter le suivi)\n")
try:
subprocess.run(["docker", "logs", "--tail", "100", "-f", container_id])
except KeyboardInterrupt:
print("\n Suivi des logs interrompu.")
def action_update(app: dict):
"""Met à jour l'application avec docker compose pull puis up -d."""
print(f"\n Mise à jour de '{app['name']}'...")
print_separator()
print(" → docker compose pull")
result_pull = subprocess.run(
["docker", "compose", "-f", app["compose_file"], "pull"],
cwd=app["path"],
)
if result_pull.returncode != 0:
print(" Erreur lors du pull.")
input(" Appuyez sur Entrée pour continuer...")
return
print("\n → docker compose up -d")
result_up = subprocess.run(
["docker", "compose", "-f", app["compose_file"], "up", "-d"],
cwd=app["path"],
)
if result_up.returncode == 0:
print("\n Mise à jour terminée avec succès.")
else:
print("\n Erreur lors du redémarrage des conteneurs.")
input("\n Appuyez sur Entrée pour continuer...")
def app_menu(app: dict):
"""Menu des actions disponibles pour une application."""
while True:
os.system("clear")
print_header()
print(f" Application : {app['name']}")
print(f" Répertoire : {app['path']}")
print_separator()
running = get_running_containers(app)
if running:
print(f" Services actifs : {', '.join(running)}")
else:
print(" Aucun service actif détecté.")
print()
print(" [1] Ouvrir un shell bash dans un conteneur")
print(" [2] Voir les logs d'un conteneur")
print(" [3] Mettre à jour (docker compose pull + up -d)")
print(" [0] Retour")
print()
choice = input(" Votre choix : ").strip()
if choice == "0":
break
elif choice in ("1", "2"):
os.system("clear")
print_header()
label = "shell bash" if choice == "1" else "logs"
print(f" Choisir le service ({label}) :")
print_separator()
all_services = get_all_services(app)
if not all_services:
print(" Aucun service trouvé dans le compose.")
input(" Appuyez sur Entrée pour continuer...")
continue
idx = select_from_list(all_services, " Service")
if idx is None:
continue
service = all_services[idx]
if choice == "1":
action_bash(app, service)
else:
action_logs(app, service)
elif choice == "3":
action_update(app)
else:
print(" Choix invalide.")
def get_all_services(app: dict) -> list[str]:
"""Retourne tous les services définis dans le docker-compose (actifs ou non)."""
try:
result = subprocess.run(
["docker", "compose", "-f", app["compose_file"], "config", "--services"],
capture_output=True,
text=True,
cwd=app["path"],
)
services = [s.strip() for s in result.stdout.splitlines() if s.strip()]
return services
except FileNotFoundError:
return []
def main():
if os.geteuid() != 0:
print("Attention : ce script nécessite des droits root pour interagir avec Docker.")
print("Relancez avec : sudo python3 manage_vps.py\n")
while True:
os.system("clear")
print_header()
apps = find_docker_apps()
if not apps:
print(f" Aucune application Docker trouvée sous {APPS_BASE_DIR}/")
print(" (recherche de docker-compose.yml dans chaque sous-dossier)")
sys.exit(0)
print(f" {len(apps)} application(s) détectée(s) :\n")
app_names = [a["name"] for a in apps]
idx = select_from_list(app_names, " Choisir une application")
if idx is None:
print("\n Au revoir !")
sys.exit(0)
app_menu(apps[idx])
if __name__ == "__main__":
main()