feat: Add Docker image update system (TrueNAS Scale inspired)
- Implement UpdateService for image version checking and atomic updates - Add DockerComposeManager for centralized docker-compose management - Create 12 docker-compose references in /home/innotex/Docker - Add 13 new API endpoints (6 for images, 7 for compose management) - Add comprehensive documentation and examples
This commit is contained in:
141
backend/app/api/endpoints/compose.py
Normal file
141
backend/app/api/endpoints/compose.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing import List
|
||||
from app.core.security import get_current_user, User
|
||||
from app.services.compose_manager import DockerComposeManager
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
compose_manager = DockerComposeManager()
|
||||
|
||||
|
||||
class ComposeFile(BaseModel):
|
||||
name: str
|
||||
file: str
|
||||
path: str
|
||||
exists: bool
|
||||
|
||||
|
||||
@router.get("/compose/list", response_model=List[ComposeFile])
|
||||
async def list_compose_files(current_user: User = Depends(get_current_user)):
|
||||
"""Liste tous les docker-compose disponibles dans /home/innotex/Docker"""
|
||||
return compose_manager.discover_compose_files()
|
||||
|
||||
|
||||
@router.get("/compose/{compose_name}/status")
|
||||
async def get_compose_status(
|
||||
compose_name: str,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Récupère l'état des conteneurs d'un docker-compose"""
|
||||
result = compose_manager.get_compose_status(f"docker-compose.{compose_name}.yml")
|
||||
|
||||
if result.get("status") == "error":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result.get("message", "Erreur inconnue")
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/compose/{compose_name}/start")
|
||||
async def start_compose(
|
||||
compose_name: str,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Démarre les conteneurs d'un docker-compose"""
|
||||
result = compose_manager.start_compose(f"docker-compose.{compose_name}.yml")
|
||||
|
||||
if result.get("status") == "error":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result.get("message", "Erreur inconnue")
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/compose/{compose_name}/stop")
|
||||
async def stop_compose(
|
||||
compose_name: str,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Arrête les conteneurs d'un docker-compose"""
|
||||
result = compose_manager.stop_compose(f"docker-compose.{compose_name}.yml")
|
||||
|
||||
if result.get("status") == "error":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result.get("message", "Erreur inconnue")
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/compose/{compose_name}/down")
|
||||
async def down_compose(
|
||||
compose_name: str,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Arrête et supprime les conteneurs d'un docker-compose"""
|
||||
result = compose_manager.down_compose(f"docker-compose.{compose_name}.yml")
|
||||
|
||||
if result.get("status") == "error":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result.get("message", "Erreur inconnue")
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/compose/{compose_name}/restart")
|
||||
async def restart_compose(
|
||||
compose_name: str,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Redémarre les conteneurs d'un docker-compose"""
|
||||
result = compose_manager.restart_compose(f"docker-compose.{compose_name}.yml")
|
||||
|
||||
if result.get("status") == "error":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result.get("message", "Erreur inconnue")
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/compose/{compose_name}/pull")
|
||||
async def pull_images(
|
||||
compose_name: str,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Pull (met à jour) les images d'un docker-compose"""
|
||||
result = compose_manager.pull_compose_images(f"docker-compose.{compose_name}.yml")
|
||||
|
||||
if result.get("status") == "error":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result.get("message", "Erreur inconnue")
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/compose/{compose_name}/logs")
|
||||
async def get_logs(
|
||||
compose_name: str,
|
||||
tail: int = 100,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Récupère les logs d'un docker-compose"""
|
||||
result = compose_manager.logs_compose(f"docker-compose.{compose_name}.yml", tail=tail)
|
||||
|
||||
if result.get("status") == "error":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result.get("message", "Erreur inconnue")
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -2,9 +2,11 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing import List
|
||||
from app.core.security import get_current_user, User
|
||||
from app.services.docker_service import DockerService, ContainerInfo
|
||||
from app.services.update_service import UpdateService, ImageInfo, ImageUpdate
|
||||
|
||||
router = APIRouter()
|
||||
docker_service = DockerService()
|
||||
update_service = UpdateService()
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
@@ -117,3 +119,155 @@ async def delete_container(
|
||||
)
|
||||
|
||||
return {"status": "success", "message": f"Conteneur {container_id} supprimé"}
|
||||
|
||||
|
||||
# ===== ENDPOINTS DE MISE À JOUR =====
|
||||
|
||||
|
||||
@router.get("/images", response_model=List[ImageInfo])
|
||||
async def list_images(current_user: User = Depends(get_current_user)):
|
||||
"""Liste toutes les images Docker locales"""
|
||||
if not update_service.is_connected():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Docker n'est pas accessible"
|
||||
)
|
||||
return update_service.get_all_images_info()
|
||||
|
||||
|
||||
@router.get("/images/check-update/{image_name}")
|
||||
async def check_image_update(
|
||||
image_name: str,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Vérifie si une image a une mise à jour disponible"""
|
||||
if not update_service.is_connected():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Docker n'est pas accessible"
|
||||
)
|
||||
|
||||
update_info = update_service.check_image_updates(image_name)
|
||||
if not update_info:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Impossible de vérifier les mises à jour pour {image_name}"
|
||||
)
|
||||
|
||||
return update_info
|
||||
|
||||
|
||||
@router.get("/images/check-all-updates")
|
||||
async def check_all_updates(current_user: User = Depends(get_current_user)):
|
||||
"""Vérifie les mises à jour de toutes les images en cours d'utilisation"""
|
||||
if not update_service.is_connected():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Docker n'est pas accessible"
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services.docker_service import DockerService
|
||||
ds = DockerService()
|
||||
containers = ds.get_containers(all=False) # Conteneurs actifs uniquement
|
||||
|
||||
updates = []
|
||||
for container in containers:
|
||||
image_name = container.image
|
||||
update_info = update_service.check_image_updates(image_name)
|
||||
if update_info:
|
||||
updates.append({
|
||||
"container": container.name,
|
||||
"update": update_info
|
||||
})
|
||||
|
||||
return {
|
||||
"total_containers": len(containers),
|
||||
"containers_with_updates": len(updates),
|
||||
"updates": updates
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Erreur lors de la vérification des mises à jour: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/images/pull")
|
||||
async def pull_image(
|
||||
image: str,
|
||||
tag: str = "latest",
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Télécharge une image Docker"""
|
||||
if not update_service.is_connected():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Docker n'est pas accessible"
|
||||
)
|
||||
|
||||
success = update_service.pull_image(image, tag)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Impossible de télécharger l'image {image}:{tag}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Image {image}:{tag} téléchargée avec succès"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/containers/{container_id}/update-image")
|
||||
async def update_container_image(
|
||||
container_id: str,
|
||||
new_image: str,
|
||||
new_tag: str = "latest",
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Met à jour l'image d'un conteneur avec la dernière version
|
||||
|
||||
Processus inspiré de TrueNAS Scale:
|
||||
1. Arrête le conteneur
|
||||
2. Télécharge la nouvelle image
|
||||
3. Redémarre le conteneur
|
||||
"""
|
||||
if not update_service.is_connected():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Docker n'est pas accessible"
|
||||
)
|
||||
|
||||
result = update_service.update_container_image(container_id, new_image, new_tag)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result.get("message", "Erreur lors de la mise à jour")
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/images/prune")
|
||||
async def prune_images(
|
||||
dangling_only: bool = True,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Nettoie les images inutilisées (similaire à TrueNAS Scale)"""
|
||||
if not update_service.is_connected():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Docker n'est pas accessible"
|
||||
)
|
||||
|
||||
result = update_service.prune_unused_images(dangling_only=dangling_only)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result.get("message", "Erreur lors du nettoyage")
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.endpoints import auth, system, docker, packages, shortcuts
|
||||
from app.api.endpoints import auth, system, docker, packages, shortcuts, compose
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
|
||||
api_router.include_router(system.router, prefix="/system", tags=["system"])
|
||||
api_router.include_router(docker.router, prefix="/docker", tags=["docker"])
|
||||
api_router.include_router(compose.router, prefix="/docker", tags=["docker-compose"])
|
||||
api_router.include_router(packages.router, prefix="/packages", tags=["packages"])
|
||||
api_router.include_router(shortcuts.router, prefix="/shortcuts", tags=["shortcuts"])
|
||||
|
||||
276
backend/app/services/compose_manager.py
Normal file
276
backend/app/services/compose_manager.py
Normal file
@@ -0,0 +1,276 @@
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
from typing import List, Dict, Optional
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DockerComposeManager:
|
||||
"""Manager pour gérer les docker-compose localisés dans /home/innotex/Docker
|
||||
|
||||
Inspiré de TrueNAS Scale avec support pour:
|
||||
- Découverte automatique des docker-compose
|
||||
- Gestion centralisée des conteneurs
|
||||
- Mise à jour des images
|
||||
"""
|
||||
|
||||
DOCKER_COMPOSE_DIR = "/home/innotex/Docker"
|
||||
|
||||
def __init__(self):
|
||||
self.docker_dir = Path(self.DOCKER_COMPOSE_DIR)
|
||||
if not self.docker_dir.exists():
|
||||
logger.warning(f"Répertoire Docker absent: {self.DOCKER_COMPOSE_DIR}")
|
||||
|
||||
def discover_compose_files(self) -> List[Dict[str, str]]:
|
||||
"""Découvre tous les docker-compose.*.yml dans le répertoire"""
|
||||
compose_files = []
|
||||
|
||||
if not self.docker_dir.exists():
|
||||
return compose_files
|
||||
|
||||
try:
|
||||
for file in sorted(self.docker_dir.glob("docker-compose.*.yml")):
|
||||
app_name = file.stem.replace("docker-compose.", "")
|
||||
|
||||
compose_files.append({
|
||||
"name": app_name,
|
||||
"file": file.name,
|
||||
"path": str(file),
|
||||
"exists": file.exists()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la découverte des docker-compose: {e}")
|
||||
|
||||
return compose_files
|
||||
|
||||
def get_compose_status(self, compose_file: str) -> Dict:
|
||||
"""Récupère l'état des conteneurs d'un docker-compose"""
|
||||
try:
|
||||
file_path = self.docker_dir / compose_file
|
||||
|
||||
if not file_path.exists():
|
||||
return {"status": "error", "message": f"Fichier non trouvé: {compose_file}"}
|
||||
|
||||
# Exécuter docker-compose ps
|
||||
result = subprocess.run(
|
||||
["docker-compose", "-f", str(file_path), "ps", "--format=json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(self.docker_dir),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return {"status": "error", "message": result.stderr}
|
||||
|
||||
try:
|
||||
containers = json.loads(result.stdout) if result.stdout else []
|
||||
return {
|
||||
"status": "success",
|
||||
"file": compose_file,
|
||||
"containers": containers,
|
||||
"count": len(containers)
|
||||
}
|
||||
except json.JSONDecodeError:
|
||||
# Fallback si le format JSON n'est pas disponible
|
||||
return {
|
||||
"status": "success",
|
||||
"file": compose_file,
|
||||
"output": result.stdout,
|
||||
"count": len(result.stdout.strip().split('\n'))
|
||||
}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "error", "message": "Timeout lors de la récupération du statut"}
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
def start_compose(self, compose_file: str) -> Dict:
|
||||
"""Démarre les conteneurs d'un docker-compose"""
|
||||
try:
|
||||
file_path = self.docker_dir / compose_file
|
||||
|
||||
if not file_path.exists():
|
||||
return {"status": "error", "message": f"Fichier non trouvé: {compose_file}"}
|
||||
|
||||
result = subprocess.run(
|
||||
["docker-compose", "-f", str(file_path), "up", "-d"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(self.docker_dir),
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Services démarrés pour {compose_file}",
|
||||
"output": result.stdout
|
||||
}
|
||||
else:
|
||||
return {"status": "error", "message": result.stderr}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "error", "message": "Timeout lors du démarrage des services"}
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
def stop_compose(self, compose_file: str) -> Dict:
|
||||
"""Arrête les conteneurs d'un docker-compose"""
|
||||
try:
|
||||
file_path = self.docker_dir / compose_file
|
||||
|
||||
if not file_path.exists():
|
||||
return {"status": "error", "message": f"Fichier non trouvé: {compose_file}"}
|
||||
|
||||
result = subprocess.run(
|
||||
["docker-compose", "-f", str(file_path), "stop"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(self.docker_dir),
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Services arrêtés pour {compose_file}",
|
||||
"output": result.stdout
|
||||
}
|
||||
else:
|
||||
return {"status": "error", "message": result.stderr}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "error", "message": "Timeout lors de l'arrêt des services"}
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
def down_compose(self, compose_file: str) -> Dict:
|
||||
"""Arrête et supprime les conteneurs d'un docker-compose"""
|
||||
try:
|
||||
file_path = self.docker_dir / compose_file
|
||||
|
||||
if not file_path.exists():
|
||||
return {"status": "error", "message": f"Fichier non trouvé: {compose_file}"}
|
||||
|
||||
result = subprocess.run(
|
||||
["docker-compose", "-f", str(file_path), "down"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(self.docker_dir),
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Services arrêtés et supprimés pour {compose_file}",
|
||||
"output": result.stdout
|
||||
}
|
||||
else:
|
||||
return {"status": "error", "message": result.stderr}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "error", "message": "Timeout lors de la suppression des services"}
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
def restart_compose(self, compose_file: str) -> Dict:
|
||||
"""Redémarre les conteneurs d'un docker-compose"""
|
||||
try:
|
||||
file_path = self.docker_dir / compose_file
|
||||
|
||||
if not file_path.exists():
|
||||
return {"status": "error", "message": f"Fichier non trouvé: {compose_file}"}
|
||||
|
||||
result = subprocess.run(
|
||||
["docker-compose", "-f", str(file_path), "restart"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(self.docker_dir),
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Services redémarrés pour {compose_file}",
|
||||
"output": result.stdout
|
||||
}
|
||||
else:
|
||||
return {"status": "error", "message": result.stderr}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "error", "message": "Timeout lors du redémarrage des services"}
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
def pull_compose_images(self, compose_file: str) -> Dict:
|
||||
"""Pull les images d'un docker-compose"""
|
||||
try:
|
||||
file_path = self.docker_dir / compose_file
|
||||
|
||||
if not file_path.exists():
|
||||
return {"status": "error", "message": f"Fichier non trouvé: {compose_file}"}
|
||||
|
||||
result = subprocess.run(
|
||||
["docker-compose", "-f", str(file_path), "pull"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(self.docker_dir),
|
||||
timeout=300
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Images téléchargées pour {compose_file}",
|
||||
"output": result.stdout
|
||||
}
|
||||
else:
|
||||
return {"status": "error", "message": result.stderr}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "error", "message": "Timeout lors du téléchargement des images"}
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
def logs_compose(self, compose_file: str, tail: int = 100) -> Dict:
|
||||
"""Récupère les logs d'un docker-compose"""
|
||||
try:
|
||||
file_path = self.docker_dir / compose_file
|
||||
|
||||
if not file_path.exists():
|
||||
return {"status": "error", "message": f"Fichier non trouvé: {compose_file}"}
|
||||
|
||||
result = subprocess.run(
|
||||
["docker-compose", "-f", str(file_path), "logs", "--tail", str(tail)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(self.docker_dir),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return {
|
||||
"status": "success",
|
||||
"file": compose_file,
|
||||
"logs": result.stdout
|
||||
}
|
||||
else:
|
||||
return {"status": "error", "message": result.stderr}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "error", "message": "Timeout lors de la récupération des logs"}
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
345
backend/app/services/update_service.py
Normal file
345
backend/app/services/update_service.py
Normal file
@@ -0,0 +1,345 @@
|
||||
import docker
|
||||
from docker.errors import DockerException
|
||||
from typing import List, Optional, Dict
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImageUpdate(BaseModel):
|
||||
image: str
|
||||
current_tag: str
|
||||
latest_tag: Optional[str]
|
||||
has_update: bool
|
||||
registry: str
|
||||
|
||||
|
||||
class ImageInfo(BaseModel):
|
||||
image: str
|
||||
tag: str
|
||||
image_id: str
|
||||
created: str
|
||||
size: str
|
||||
containers_using: List[str]
|
||||
|
||||
|
||||
class UpdateService:
|
||||
"""Service pour gérer les mises à jour des images Docker
|
||||
Inspiré de TrueNAS Scale"""
|
||||
|
||||
def __init__(self):
|
||||
try:
|
||||
self.client = docker.from_env()
|
||||
except DockerException as e:
|
||||
logger.error(f"Erreur de connexion Docker: {e}")
|
||||
self.client = None
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Vérifie si Docker est accessible"""
|
||||
try:
|
||||
if self.client:
|
||||
self.client.ping()
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
return False
|
||||
|
||||
def parse_image_name(self, image_full_name: str) -> Dict[str, str]:
|
||||
"""Parse le nom complet d'une image Docker
|
||||
|
||||
Format: [registry/]repository[:tag][@digest]
|
||||
Exemple: docker.io/library/nginx:latest
|
||||
"""
|
||||
# Retirer le digest si présent
|
||||
image_full_name = image_full_name.split('@')[0]
|
||||
|
||||
# Parser le registry, repository et tag
|
||||
registry = "docker.io"
|
||||
tag = "latest"
|
||||
|
||||
# Vérifier si un registry personnalisé est spécifié
|
||||
if '/' in image_full_name:
|
||||
parts = image_full_name.split('/')
|
||||
if '.' in parts[0] or ':' in parts[0] or parts[0] == 'localhost':
|
||||
registry = parts[0]
|
||||
image_repo = '/'.join(parts[1:])
|
||||
else:
|
||||
image_repo = image_full_name
|
||||
else:
|
||||
image_repo = image_full_name
|
||||
|
||||
# Parser le tag
|
||||
if ':' in image_repo:
|
||||
image_repo, tag = image_repo.rsplit(':', 1)
|
||||
|
||||
# Si pas de slash dans image_repo, c'est une image officielle
|
||||
if '/' not in image_repo:
|
||||
image_repo = f"library/{image_repo}"
|
||||
|
||||
return {
|
||||
"registry": registry,
|
||||
"repository": image_repo,
|
||||
"tag": tag,
|
||||
"full_name": image_full_name
|
||||
}
|
||||
|
||||
def get_all_images_info(self) -> List[ImageInfo]:
|
||||
"""Récupère toutes les images locales et les conteneurs qui les utilisent"""
|
||||
if not self.is_connected():
|
||||
return []
|
||||
|
||||
images_info = []
|
||||
try:
|
||||
containers = {c.image.id: [] for c in self.client.containers.list(all=True)}
|
||||
for container in self.client.containers.list(all=True):
|
||||
containers[container.image.id].append(container.name)
|
||||
|
||||
for image in self.client.images.list():
|
||||
for tag in (image.tags or ["<none>"]):
|
||||
size_bytes = image.attrs.get('Size', 0)
|
||||
size_mb = size_bytes / (1024 * 1024)
|
||||
|
||||
created = image.attrs.get('Created', '')
|
||||
|
||||
images_info.append(ImageInfo(
|
||||
image=tag.split(':')[0] if ':' in tag else tag,
|
||||
tag=tag.split(':')[1] if ':' in tag else "latest",
|
||||
image_id=image.short_id,
|
||||
created=created,
|
||||
size=f"{size_mb:.2f} MB",
|
||||
containers_using=containers.get(image.id, [])
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la récupération des images: {e}")
|
||||
|
||||
return images_info
|
||||
|
||||
def check_image_updates(self, image_name: str) -> Optional[ImageUpdate]:
|
||||
"""Vérifie si une image a une mise à jour disponible
|
||||
|
||||
Utilise Docker Registry V2 API pour vérifier les tags disponibles
|
||||
"""
|
||||
if not self.is_connected():
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = self.parse_image_name(image_name)
|
||||
registry = parsed['registry']
|
||||
repository = parsed['repository']
|
||||
current_tag = parsed['tag']
|
||||
|
||||
# Pour docker.io, utiliser le registry officiel
|
||||
if registry == "docker.io":
|
||||
registry_url = "https://registry-1.docker.io/v2"
|
||||
# Auth token pour docker.io
|
||||
import requests
|
||||
# Obtenir le token d'authentification
|
||||
auth_url = f"https://auth.docker.io/token?service=registry.docker.io&scope=repository:{repository}:pull"
|
||||
try:
|
||||
auth_response = requests.get(auth_url, timeout=5)
|
||||
token = auth_response.json().get('token', '')
|
||||
except:
|
||||
token = ""
|
||||
|
||||
# Récupérer les tags disponibles
|
||||
headers = {}
|
||||
if token:
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
|
||||
try:
|
||||
tags_response = requests.get(
|
||||
f"{registry_url}/{repository}/tags/list",
|
||||
headers=headers,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if tags_response.status_code == 200:
|
||||
tags = tags_response.json().get('tags', [])
|
||||
|
||||
# Chercher le dernier tag stable
|
||||
latest_tag = self._find_latest_tag(tags, current_tag)
|
||||
|
||||
has_update = latest_tag != current_tag
|
||||
|
||||
return ImageUpdate(
|
||||
image=image_name,
|
||||
current_tag=current_tag,
|
||||
latest_tag=latest_tag,
|
||||
has_update=has_update,
|
||||
registry=registry
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Erreur lors de la vérification des tags: {e}")
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la vérification des mises à jour: {e}")
|
||||
return None
|
||||
|
||||
def _find_latest_tag(self, tags: List[str], current_tag: str) -> str:
|
||||
"""Trouve le dernier tag stable parmi une liste de tags"""
|
||||
if not tags:
|
||||
return current_tag
|
||||
|
||||
# Filtrer les tags non valides
|
||||
stable_tags = [t for t in tags if t and t != 'latest' and not re.search(r'-(rc|alpha|beta|dev)', t)]
|
||||
|
||||
if not stable_tags:
|
||||
stable_tags = tags
|
||||
|
||||
# Trier par version sémantique
|
||||
def parse_version(v):
|
||||
try:
|
||||
parts = [int(x) for x in v.split('.')[:3]]
|
||||
while len(parts) < 3:
|
||||
parts.append(0)
|
||||
return tuple(parts)
|
||||
except:
|
||||
return (0, 0, 0)
|
||||
|
||||
try:
|
||||
latest = sorted(stable_tags, key=parse_version, reverse=True)[0]
|
||||
return latest
|
||||
except:
|
||||
return stable_tags[0] if stable_tags else current_tag
|
||||
|
||||
def pull_image(self, image_name: str, tag: str = "latest") -> bool:
|
||||
"""Pull une image Docker
|
||||
|
||||
Similaire à la fonction de TrueNAS Scale
|
||||
"""
|
||||
if not self.is_connected():
|
||||
return False
|
||||
|
||||
try:
|
||||
full_image = f"{image_name}:{tag}"
|
||||
logger.info(f"Téléchargement de l'image: {full_image}")
|
||||
|
||||
# Pull l'image
|
||||
result = self.client.images.pull(full_image)
|
||||
|
||||
logger.info(f"Image téléchargée avec succès: {full_image}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors du téléchargement de l'image: {e}")
|
||||
return False
|
||||
|
||||
def update_container_image(self, container_id: str, new_image: str, new_tag: str = "latest") -> Dict:
|
||||
"""Met à jour l'image d'un conteneur
|
||||
|
||||
Processus similaire à TrueNAS Scale:
|
||||
1. Stop le conteneur
|
||||
2. Pull la nouvelle image
|
||||
3. Redémarre le conteneur avec la nouvelle image
|
||||
"""
|
||||
if not self.is_connected():
|
||||
return {"success": False, "message": "Docker n'est pas accessible"}
|
||||
|
||||
try:
|
||||
container = self.client.containers.get(container_id)
|
||||
container_name = container.name
|
||||
old_image = container.image.tags[0] if container.image.tags else container.image.id[:12]
|
||||
|
||||
logger.info(f"Mise à jour du conteneur {container_name}")
|
||||
|
||||
# 1. Arrêter le conteneur
|
||||
try:
|
||||
container.stop(timeout=10)
|
||||
logger.info(f"Conteneur {container_name} arrêté")
|
||||
except Exception as e:
|
||||
logger.warning(f"Erreur à l'arrêt du conteneur: {e}")
|
||||
|
||||
# 2. Pull la nouvelle image
|
||||
full_image = f"{new_image}:{new_tag}"
|
||||
if not self.pull_image(new_image, new_tag):
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Impossible de télécharger l'image {full_image}"
|
||||
}
|
||||
|
||||
# 3. Redémarrer le conteneur avec la nouvelle image
|
||||
try:
|
||||
# Récupérer la configuration du conteneur
|
||||
config = container.attrs
|
||||
|
||||
# Supprimer l'ancien conteneur
|
||||
container.remove()
|
||||
logger.info(f"Ancien conteneur supprimé")
|
||||
|
||||
# Créer un nouveau conteneur avec la nouvelle image
|
||||
new_container = self.client.containers.run(
|
||||
full_image,
|
||||
name=container_name,
|
||||
detach=True,
|
||||
**{k: v for k, v in config['HostConfig'].items() if k not in ['Binds']}
|
||||
)
|
||||
|
||||
logger.info(f"Nouveau conteneur créé avec l'image {full_image}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Conteneur {container_name} mis à jour avec succès",
|
||||
"old_image": old_image,
|
||||
"new_image": full_image
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la mise à jour du conteneur: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Erreur lors de la mise à jour: {str(e)}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur générale: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Erreur: {str(e)}"
|
||||
}
|
||||
|
||||
def get_image_history(self, image_name: str) -> Dict:
|
||||
"""Récupère l'historique d'une image (layers)"""
|
||||
if not self.is_connected():
|
||||
return {}
|
||||
|
||||
try:
|
||||
image = self.client.images.get(image_name)
|
||||
history = image.attrs.get('History', [])
|
||||
return {
|
||||
"image": image_name,
|
||||
"layers_count": len(history),
|
||||
"history": history
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la récupération de l'historique: {e}")
|
||||
return {}
|
||||
|
||||
def prune_unused_images(self, dangling_only: bool = False) -> Dict:
|
||||
"""Nettoie les images inutilisées
|
||||
|
||||
Similaire à la fonction de TrueNAS Scale
|
||||
"""
|
||||
if not self.is_connected():
|
||||
return {"success": False, "message": "Docker n'est pas accessible"}
|
||||
|
||||
try:
|
||||
result = self.client.images.prune(filters={"dangling": dangling_only})
|
||||
|
||||
deleted_count = len(result.get('ImagesDeleted', []))
|
||||
space_freed = result.get('SpaceFreed', 0) / (1024 * 1024) # MB
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"deleted_images": deleted_count,
|
||||
"space_freed_mb": f"{space_freed:.2f}",
|
||||
"details": result.get('ImagesDeleted', [])
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors du nettoyage des images: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Erreur: {str(e)}"
|
||||
}
|
||||
15
backend/config/shortcuts.json
Normal file
15
backend/config/shortcuts.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"shortcuts": [
|
||||
{
|
||||
"id": "shortcut_1768585316580",
|
||||
"name": "OpenWebUI",
|
||||
"url": "http://localhost:3000",
|
||||
"icon": "🔗",
|
||||
"description": "",
|
||||
"category": "GPT",
|
||||
"color": "#3B82F6",
|
||||
"order": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user