etoile_polaire/server/app/services/docker.py
2025-04-01 16:33:54 +02:00

387 lines
No EOL
17 KiB
Python

import docker
from typing import List, Dict, Any, Optional, AsyncGenerator
import asyncio
import logging
from ..models.container import Container, ContainerStats, NetworkStats, PortBinding
logger = logging.getLogger(__name__)
class DockerService:
def __init__(self):
"""Initialise le service Docker."""
self.client = None
self.init_client()
def init_client(self):
"""Initialise ou réinitialise le client Docker."""
try:
if self.client:
try:
# Tester si le client existant est toujours valide
self.client.ping()
logger.info("Client Docker existant est valide")
return
except:
logger.warning("Client Docker existant n'est plus valide")
self.client = None
self.client = docker.from_env()
self.client.ping() # Vérifier que la connexion fonctionne
logger.info("Nouveau client Docker initialisé avec succès")
except Exception as e:
logger.error(f"Erreur lors de l'initialisation du client Docker: {e}")
raise
def ensure_client(self):
"""S'assure que le client Docker est initialisé et valide."""
try:
if not self.client:
logger.warning("Client Docker non initialisé, tentative de réinitialisation")
self.init_client()
return
# Test simple pour vérifier que le client est fonctionnel
self.client.ping()
except Exception as e:
logger.warning(f"Client Docker invalide, tentative de réinitialisation: {e}")
self.init_client()
def _parse_port_bindings(self, ports: Dict) -> List[PortBinding]:
"""Convertit les ports Docker en modèle PortBinding."""
bindings = []
if not ports:
return bindings
for container_port, host_bindings in ports.items():
if not host_bindings:
continue
# Format: "8080/tcp"
port, proto = container_port.split("/")
for binding in host_bindings:
bindings.append(PortBinding(
host_ip=binding.get("HostIp", "0.0.0.0"),
host_port=int(binding.get("HostPort", port)),
container_port=int(port),
protocol=proto
))
return bindings
def _parse_environment(self, env: List[str]) -> Dict[str, str]:
"""Convertit les variables d'environnement en dictionnaire."""
result = {}
if not env:
return result
for var in env:
if "=" in var:
key, value = var.split("=", 1)
result[key] = value
return result
async def list_containers(self) -> List[Container]:
"""Liste tous les conteneurs Docker."""
try:
self.ensure_client()
logger.info("Début de la récupération de la liste des conteneurs")
containers = self.client.containers.list(all=True)
logger.info(f"Nombre de conteneurs trouvés: {len(containers)}")
result = []
for container in containers:
try:
attrs = container.attrs
container_model = Container(
id=container.id,
name=container.name,
status=container.status,
image=container.image.tags[0] if container.image.tags else "none",
created=attrs['Created'],
ports=self._parse_port_bindings(attrs['NetworkSettings']['Ports']),
volumes=[v['Source'] for v in attrs['Mounts']],
environment=self._parse_environment(attrs['Config']['Env']),
networks=list(attrs['NetworkSettings']['Networks'].keys()),
health_status=attrs.get('State', {}).get('Health', {}).get('Status'),
restart_policy=attrs['HostConfig']['RestartPolicy']['Name'],
command=attrs['Config']['Cmd'][0] if attrs['Config']['Cmd'] else None
)
result.append(container_model)
logger.debug(f"Conteneur ajouté: {container.name}")
except Exception as e:
logger.error(f"Erreur lors de la conversion du conteneur {container.name}: {e}")
continue
return result
except Exception as e:
logger.error(f"Erreur lors de la liste des conteneurs: {e}")
raise
async def get_container(self, container_id: str) -> Container:
"""Récupère les informations d'un conteneur spécifique."""
self.ensure_client()
container = self.client.containers.get(container_id)
attrs = container.attrs
return Container(
id=container.id,
name=container.name,
status=container.status,
image=container.image.tags[0] if container.image.tags else "none",
created=attrs['Created'],
ports=self._parse_port_bindings(attrs['NetworkSettings']['Ports']),
volumes=[v['Source'] for v in attrs['Mounts']],
environment=self._parse_environment(attrs['Config']['Env']),
networks=list(attrs['NetworkSettings']['Networks'].keys()),
health_status=attrs.get('State', {}).get('Health', {}).get('Status'),
restart_policy=attrs['HostConfig']['RestartPolicy']['Name'],
command=attrs['Config']['Cmd'][0] if attrs['Config']['Cmd'] else None
)
async def get_container_logs(self, container_id: str, tail: int = 100) -> List[Dict[str, str]]:
"""Récupère les logs d'un conteneur."""
try:
self.ensure_client()
container = self.client.containers.get(container_id)
if not container:
logger.error(f"Conteneur {container_id} non trouvé")
return []
logger.info(f"Récupération des logs pour le conteneur {container_id}")
# Utilisation de l'API Docker pour obtenir les logs
logs = container.logs(
tail=tail,
timestamps=True,
stdout=True,
stderr=True
)
if not logs:
logger.warning(f"Aucun log trouvé pour le conteneur {container_id}")
return []
decoded_logs = logs.decode('utf-8')
log_lines = []
for line in decoded_logs.split('\n'):
if not line.strip():
continue
# Format attendu: "2024-04-01T11:10:27.000000000Z message..."
try:
timestamp, message = line.split(' ', 1)
log_lines.append({
"timestamp": timestamp,
"message": message.strip(),
"stream": "stdout" # Par défaut stdout
})
except ValueError:
# Si le format n'est pas celui attendu, on garde la ligne complète
log_lines.append({
"timestamp": "",
"message": line.strip(),
"stream": "stdout"
})
logger.info(f"Nombre de lignes de logs trouvées : {len(log_lines)}")
return log_lines
except docker.errors.NotFound:
logger.error(f"Conteneur {container_id} non trouvé")
return []
except Exception as e:
logger.error(f"Erreur lors de la récupération des logs du conteneur {container_id}: {e}")
raise
async def get_container_stats(self, container_id: str) -> ContainerStats:
"""Récupère les statistiques d'un conteneur."""
try:
self.ensure_client()
container = self.client.containers.get(container_id)
stats = container.stats(stream=False)
logger.debug(f"Stats brutes reçues pour {container_id}: {stats}")
# Vérifier la présence des clés nécessaires
if not all(key in stats for key in ['cpu_stats', 'precpu_stats', 'memory_stats']):
logger.error(f"Données de stats incomplètes pour le conteneur {container_id}")
raise ValueError("Données de statistiques incomplètes")
# Calculer le pourcentage CPU avec vérification des valeurs
try:
cpu_delta = stats['cpu_stats']['cpu_usage']['total_usage'] - stats['precpu_stats']['cpu_usage']['total_usage']
system_delta = stats['cpu_stats']['system_cpu_usage'] - stats['precpu_stats']['system_cpu_usage']
online_cpus = stats['cpu_stats'].get('online_cpus', 1) # Utiliser 1 comme valeur par défaut
if system_delta > 0:
cpu_percent = (cpu_delta / system_delta) * 100.0 * online_cpus
else:
cpu_percent = 0.0
except (KeyError, TypeError) as e:
logger.warning(f"Erreur lors du calcul du CPU pour {container_id}: {e}")
cpu_percent = 0.0
# Convertir les statistiques réseau avec gestion des erreurs
networks = {}
for net_name, net_stats in stats.get('networks', {}).items():
try:
networks[net_name] = NetworkStats(
rx_bytes=net_stats.get('rx_bytes', 0),
rx_packets=net_stats.get('rx_packets', 0),
rx_errors=net_stats.get('rx_errors', 0),
rx_dropped=net_stats.get('rx_dropped', 0),
tx_bytes=net_stats.get('tx_bytes', 0),
tx_packets=net_stats.get('tx_packets', 0),
tx_errors=net_stats.get('tx_errors', 0),
tx_dropped=net_stats.get('tx_dropped', 0)
)
except Exception as e:
logger.warning(f"Erreur lors de la conversion des stats réseau pour {net_name}: {e}")
continue
# Récupérer les statistiques de bloc avec gestion des erreurs
try:
io_stats = stats.get('blkio_stats', {}).get('io_service_bytes_recursive', [])
block_read = io_stats[0].get('value', 0) if len(io_stats) > 0 else 0
block_write = io_stats[1].get('value', 0) if len(io_stats) > 1 else 0
except (IndexError, KeyError, AttributeError) as e:
logger.warning(f"Erreur lors de la récupération des stats de bloc pour {container_id}: {e}")
block_read = 0
block_write = 0
return ContainerStats(
cpu_percent=cpu_percent,
memory_usage=stats.get('memory_stats', {}).get('usage', 0),
memory_limit=stats.get('memory_stats', {}).get('limit', 0),
network=networks,
block_read=block_read,
block_write=block_write
)
except docker.errors.NotFound:
logger.error(f"Conteneur {container_id} non trouvé")
raise
except Exception as e:
logger.error(f"Erreur lors de la récupération des stats du conteneur {container_id}: {e}")
raise
async def start_container(self, container_id: str) -> None:
"""Démarre un conteneur."""
self.ensure_client()
container = self.client.containers.get(container_id)
container.start()
async def stop_container(self, container_id: str) -> None:
"""Arrête un conteneur."""
self.ensure_client()
container = self.client.containers.get(container_id)
container.stop()
async def restart_container(self, container_id: str) -> None:
"""Redémarre un conteneur."""
self.ensure_client()
container = self.client.containers.get(container_id)
container.restart()
async def update_container(self, container_id: str) -> None:
"""Met à jour un conteneur."""
self.ensure_client()
container = self.client.containers.get(container_id)
container.restart()
async def follow_container_logs(self, container_id: str) -> AsyncGenerator[Dict[str, str], None]:
"""Suit les logs d'un conteneur en temps réel en utilisant l'API Docker Python."""
try:
self.ensure_client()
container = self.client.containers.get(container_id)
logger.info(f"Démarrage du suivi des logs pour le conteneur {container_id}")
# Utiliser l'API Docker Python pour obtenir un flux de logs continu
log_stream = container.logs(
stream=True,
follow=True,
timestamps=True,
stdout=True,
stderr=True,
tail=100 # Commencer avec les 100 derniers logs
)
for log in log_stream:
try:
# Décoder la ligne de log
line = log.decode('utf-8').strip()
if not line:
continue
logger.debug(f"Ligne de log brute reçue: {line}")
# Format attendu: "2024-04-01T11:10:27.000000000Z message..."
try:
# Trouver le premier espace qui sépare la date du message
space_index = line.find(' ')
if space_index > 0:
timestamp = line[:space_index]
message = line[space_index + 1:].strip()
logger.debug(f"Timestamp extrait: {timestamp}")
logger.debug(f"Message extrait: {message}")
# Vérifier si le timestamp est valide (format ISO 8601)
if timestamp.endswith('Z'):
# Convertir le timestamp en format ISO 8601 standard
timestamp = timestamp.replace('Z', '+00:00')
# Vérifier que le format est valide
if not timestamp.replace('.', '').replace(':', '').replace('-', '').replace('T', '').replace('+', '').isdigit():
logger.warning(f"Format de timestamp invalide: {timestamp}")
timestamp = ""
else:
logger.warning(f"Timestamp ne se termine pas par Z: {timestamp}")
timestamp = ""
yield {
"timestamp": timestamp,
"message": message,
"stream": "stdout"
}
else:
# Si pas de timestamp valide, envoyer le message sans timestamp
logger.warning(f"Pas d'espace trouvé dans la ligne: {line}")
yield {
"timestamp": "",
"message": line,
"stream": "stdout"
}
except ValueError as e:
logger.error(f"Erreur lors du parsing de la ligne: {e}")
yield {
"timestamp": "",
"message": line,
"stream": "stdout"
}
except UnicodeDecodeError:
# Fallback sur latin-1 si UTF-8 échoue
line = log.decode('latin-1').strip()
yield {
"timestamp": "",
"message": line,
"stream": "stdout"
}
except Exception as e:
logger.error(f"Erreur lors du décodage d'un log : {e}")
continue
except docker.errors.NotFound:
logger.error(f"Conteneur {container_id} non trouvé")
except Exception as e:
logger.error(f"Erreur lors du suivi des logs du conteneur {container_id}: {e}")
raise
finally:
logger.info(f"Arrêt du suivi des logs pour le conteneur {container_id}")
if 'log_stream' in locals():
try:
log_stream.close()
except:
pass