Files
lovdata-chat/session-manager/async_docker_client.py
Torbjørn Lindahl d6f2ea90a8 fix: add missing _get_container_info method to AsyncDockerClient
docker_service.get_container_info() was calling self._docker_client._get_container_info()
but AsyncDockerClient didn't have this method, causing silent AttributeError and
returning None, which triggered false health check failures.

Added _get_container_info() using aiodocker's container.show() to properly retrieve
container state information for health monitoring.
2026-02-04 22:04:29 +01:00

318 lines
11 KiB
Python

"""
Async Docker Operations Wrapper
Provides async wrappers for Docker operations to eliminate blocking calls
in FastAPI async contexts and improve concurrency and scalability.
"""
import asyncio
import logging
from typing import Dict, Optional, List, Any
from contextlib import asynccontextmanager
import os
from aiodocker import Docker
from aiodocker.containers import DockerContainer
from aiodocker.exceptions import DockerError
logger = logging.getLogger(__name__)
class AsyncDockerClient:
"""Async wrapper for Docker operations using aiodocker."""
def __init__(self):
self._docker: Optional[Docker] = None
self._connected = False
async def __aenter__(self):
await self.connect()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.disconnect()
async def connect(self):
"""Connect to Docker daemon."""
if self._connected:
return
try:
# Configure TLS/SSL context based on environment
ssl_ctx = None
if os.getenv("DOCKER_TLS_VERIFY") == "1":
import ssl
ssl_ctx = ssl.create_default_context(
cafile=os.getenv("DOCKER_CA_CERT", "/etc/docker/certs/ca.pem")
)
ssl_ctx.load_cert_chain(
certfile=os.getenv("DOCKER_CLIENT_CERT", "/etc/docker/certs/client-cert.pem"),
keyfile=os.getenv("DOCKER_CLIENT_KEY", "/etc/docker/certs/client-key.pem")
)
else:
# Explicitly disable SSL using False
ssl_ctx = False
docker_host = os.getenv("DOCKER_HOST", "tcp://host.docker.internal:2376")
self._docker = Docker(docker_host, ssl_context=ssl_ctx)
# Test connection using version() since aiodocker doesn't have ping()
await self._docker.version()
self._connected = True
logger.info("Async Docker client connected successfully")
except Exception as e:
logger.error(f"Failed to connect to Docker: {e}")
raise
async def disconnect(self):
"""Disconnect from Docker daemon."""
if self._docker and self._connected:
await self._docker.close()
self._connected = False
logger.info("Async Docker client disconnected")
async def ping(self) -> bool:
"""Test Docker connectivity."""
if not self._docker:
return False
try:
await self._docker.version()
return True
except Exception:
return False
async def create_container(
self,
image: str,
name: str,
volumes: Optional[Dict[str, Dict[str, str]]] = None,
ports: Optional[Dict[str, int]] = None,
environment: Optional[Dict[str, str]] = None,
network_mode: str = "bridge",
mem_limit: Optional[str] = None,
cpu_quota: Optional[int] = None,
cpu_period: Optional[int] = None,
tmpfs: Optional[Dict[str, str]] = None,
**kwargs,
) -> DockerContainer:
"""
Create a Docker container asynchronously.
Args:
image: Container image name
name: Container name
volumes: Volume mounts
ports: Port mappings
environment: Environment variables
network_mode: Network mode
mem_limit: Memory limit (e.g., "4g")
cpu_quota: CPU quota
cpu_period: CPU period
tmpfs: tmpfs mounts
**kwargs: Additional container configuration
Returns:
DockerContainer: The created container
"""
if not self._docker:
raise RuntimeError("Docker client not connected")
config = {
"Image": image,
"name": name,
"Volumes": volumes or {},
"ExposedPorts": {f"{port}/tcp": {} for port in ports.values()}
if ports
else {},
"Env": [f"{k}={v}" for k, v in (environment or {}).items()],
"NetworkMode": network_mode,
"HostConfig": {
"Binds": [
f"{host}:{container['bind']}:{container.get('mode', 'rw')}"
for host, container in (volumes or {}).items()
],
"PortBindings": {
f"{container_port}/tcp": [{"HostPort": str(host_port)}]
for container_port, host_port in (ports or {}).items()
},
"Tmpfs": tmpfs or {},
},
}
# Add resource limits
host_config = config["HostConfig"]
if mem_limit:
host_config["Memory"] = self._parse_memory_limit(mem_limit)
if cpu_quota is not None:
host_config["CpuQuota"] = cpu_quota
if cpu_period is not None:
host_config["CpuPeriod"] = cpu_period
# Add any additional host config
host_config.update(kwargs.get("host_config", {}))
try:
container = await self._docker.containers.create(config)
logger.info(f"Container {name} created successfully")
return container
except DockerError as e:
logger.error(f"Failed to create container {name}: {e}")
raise
async def start_container(self, container: DockerContainer) -> None:
"""Start a Docker container."""
try:
await container.start()
logger.info(f"Container {container.id} started successfully")
except DockerError as e:
logger.error(f"Failed to start container {container.id}: {e}")
raise
async def stop_container(
self, container: DockerContainer, timeout: int = 10
) -> None:
"""Stop a Docker container."""
try:
await container.stop(timeout=timeout)
logger.info(f"Container {container.id} stopped successfully")
except DockerError as e:
logger.error(f"Failed to stop container {container.id}: {e}")
raise
async def remove_container(
self, container: DockerContainer, force: bool = False
) -> None:
"""Remove a Docker container."""
try:
await container.delete(force=force)
logger.info(f"Container {container.id} removed successfully")
except DockerError as e:
logger.error(f"Failed to remove container {container.id}: {e}")
raise
async def get_container(self, container_id: str) -> Optional[DockerContainer]:
"""Get a container by ID or name."""
try:
return await self._docker.containers.get(container_id)
except DockerError:
return None
async def _get_container_info(self, container_id: str) -> Optional[Dict[str, Any]]:
"""
Get detailed container information (equivalent to docker inspect).
Returns the full container info dict including State, Config, etc.
"""
try:
container = await self._docker.containers.get(container_id)
if container:
# show() returns the full container inspect data
return await container.show()
except DockerError as e:
logger.debug(f"Failed to get container info for {container_id}: {e}")
except Exception as e:
logger.debug(f"Unexpected error getting container info: {e}")
return None
async def list_containers(
self, all: bool = False, filters: Optional[Dict[str, Any]] = None
) -> List[DockerContainer]:
"""List Docker containers."""
try:
return await self._docker.containers.list(all=all, filters=filters)
except DockerError as e:
logger.error(f"Failed to list containers: {e}")
return []
async def get_container_stats(
self, container: DockerContainer
) -> Optional[Dict[str, Any]]:
"""Get container statistics."""
try:
stats = await container.stats(stream=False)
return stats
except DockerError as e:
logger.error(f"Failed to get stats for container {container.id}: {e}")
return None
async def get_system_info(self) -> Optional[Dict[str, Any]]:
"""Get Docker system information."""
if not self._docker:
return None
try:
return await self._docker.system.info()
except DockerError as e:
logger.error(f"Failed to get system info: {e}")
return None
def _parse_memory_limit(self, memory_str: str) -> int:
"""Parse memory limit string to bytes."""
memory_str = memory_str.lower().strip()
if memory_str.endswith("g"):
return int(memory_str[:-1]) * (1024**3)
elif memory_str.endswith("m"):
return int(memory_str[:-1]) * (1024**2)
elif memory_str.endswith("k"):
return int(memory_str[:-1]) * 1024
else:
return int(memory_str)
# Global async Docker client instance
_async_docker_client = AsyncDockerClient()
@asynccontextmanager
async def get_async_docker_client():
"""Context manager for async Docker client."""
async with _async_docker_client as client:
yield client
async def async_docker_ping() -> bool:
"""Async ping Docker daemon."""
async with get_async_docker_client() as client:
return await client.ping()
async def async_create_container(**kwargs) -> DockerContainer:
"""Async container creation wrapper."""
async with get_async_docker_client() as client:
return await client.create_container(**kwargs)
async def async_start_container(container: DockerContainer) -> None:
"""Async container start wrapper."""
async with get_async_docker_client() as client:
await client.start_container(container)
async def async_stop_container(container: DockerContainer, timeout: int = 10) -> None:
"""Async container stop wrapper."""
async with get_async_docker_client() as client:
await client.stop_container(container, timeout)
async def async_remove_container(
container: DockerContainer, force: bool = False
) -> None:
"""Async container removal wrapper."""
async with get_async_docker_client() as client:
await client.remove_container(container, force)
async def async_list_containers(
all: bool = False, filters: Optional[Dict[str, Any]] = None
) -> List[DockerContainer]:
"""Async container listing wrapper."""
async with get_async_docker_client() as client:
return await client.list_containers(all=all, filters=filters)
async def async_get_container(container_id: str) -> Optional[DockerContainer]:
"""Async container retrieval wrapper."""
async with get_async_docker_client() as client:
return await client.get_container(container_id)