initial commit

This commit is contained in:
Pierre
2026-02-08 16:38:58 +01:00
commit 5783b40343
13 changed files with 1095 additions and 0 deletions
+346
View File
@@ -0,0 +1,346 @@
"""Docker SDK wrapper with label-based container tracking."""
from datetime import datetime
from typing import Any, Dict, Generator, List, Optional, Union
import docker
from docker.errors import APIError, ImageNotFound, NotFound
from docker.models.containers import Container
from fastapi import HTTPException, status
from app.config import INSTANCE_LABEL, MANAGED_LABEL, settings
from app.models import (
ContainerCreate,
ContainerCreateResponse,
ContainerLogsResponse,
ContainerResponse,
ContainerStatsResponse,
)
class DockerClientError(HTTPException):
"""Exception raised for Docker client errors."""
def __init__(self, message: str, status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR):
super().__init__(status_code=status_code, detail=message)
class DockerManager:
"""Manager for Docker operations with label-based tracking."""
def __init__(self):
"""Initialize the Docker client."""
try:
self.client = docker.from_env()
except Exception as e:
raise RuntimeError(f"Failed to connect to Docker: {e}")
self.instance_id = settings.instance_id
def _get_managed_labels(self, extra_labels: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""Get the labels to apply to managed containers."""
labels = {
MANAGED_LABEL: "true",
INSTANCE_LABEL: self.instance_id,
}
if extra_labels:
labels.update(extra_labels)
return labels
def _container_to_response(self, container: Container) -> ContainerResponse:
"""Convert a Docker container to a response model."""
container.reload() # Ensure we have the latest state
attrs = container.attrs
# Parse creation time
created_str = attrs.get("Created", "")
try:
# Docker uses ISO format with nanoseconds
created = datetime.fromisoformat(created_str.replace("Z", "+00:00"))
except (ValueError, AttributeError):
created = datetime.now()
# Get port bindings
ports = {}
network_settings = attrs.get("NetworkSettings", {})
port_bindings = network_settings.get("Ports", {})
if port_bindings:
for container_port, bindings in port_bindings.items():
if bindings:
ports[container_port] = [
{"HostIp": b.get("HostIp", ""), "HostPort": b.get("HostPort", "")}
for b in bindings
]
# Get image name
image_name = attrs.get("Config", {}).get("Image", "")
if not image_name and container.image:
image_name = container.image.tags[0] if container.image.tags else container.image.short_id
return ContainerResponse(
id=container.id,
short_id=container.short_id,
name=container.name,
image=image_name,
status=container.status,
created=created,
ports=ports,
labels=attrs.get("Config", {}).get("Labels", {}),
)
def is_managed(self, container: Container) -> bool:
"""Check if a container is managed by this API."""
labels = container.labels or {}
return labels.get(MANAGED_LABEL) == "true"
def get_managed_container(self, container_id: str) -> Container:
"""Get a container by ID, ensuring it's managed by this API."""
try:
container = self.client.containers.get(container_id)
except NotFound:
raise DockerClientError(
f"Container '{container_id}' not found",
status_code=status.HTTP_404_NOT_FOUND,
)
if not self.is_managed(container):
raise DockerClientError(
f"Container '{container_id}' is not managed by this API",
status_code=status.HTTP_403_FORBIDDEN,
)
return container
def list_managed(self, all_containers: bool = True) -> List[ContainerResponse]:
"""List all containers managed by this API."""
try:
containers = self.client.containers.list(
all=all_containers,
filters={"label": f"{MANAGED_LABEL}=true"},
)
except APIError as e:
raise DockerClientError(f"Failed to list containers: {e}")
return [self._container_to_response(c) for c in containers]
def create_container(self, request: ContainerCreate) -> ContainerCreateResponse:
"""Create a new container with managed labels."""
# Prepare labels
labels = self._get_managed_labels(request.labels)
# Prepare port bindings
ports = None
if request.ports:
ports = {}
for container_port, host_binding in request.ports.items():
if isinstance(host_binding, int):
ports[container_port] = host_binding
elif isinstance(host_binding, dict):
ports[container_port] = (
host_binding.get("host_ip", "0.0.0.0"),
host_binding.get("host_port"),
)
elif isinstance(host_binding, list):
ports[container_port] = [
(b.get("host_ip", "0.0.0.0"), b.get("host_port"))
for b in host_binding
]
# Prepare volumes
volumes = None
if request.volumes:
volumes = {
host_path: {
"bind": config.bind,
"mode": config.mode,
}
for host_path, config in request.volumes.items()
}
# Prepare restart policy
restart_policy = None
if request.restart_policy:
restart_policy = {
"Name": request.restart_policy.name,
"MaximumRetryCount": request.restart_policy.maximum_retry_count,
}
# Build container options
container_options: Dict[str, Any] = {
"image": request.image,
"labels": labels,
"detach": True,
}
if request.name:
container_options["name"] = request.name
if request.command:
container_options["command"] = request.command
if request.entrypoint:
container_options["entrypoint"] = request.entrypoint
if request.environment:
container_options["environment"] = request.environment
if ports:
container_options["ports"] = ports
if volumes:
container_options["volumes"] = volumes
if restart_policy:
container_options["restart_policy"] = restart_policy
if request.working_dir:
container_options["working_dir"] = request.working_dir
if request.user:
container_options["user"] = request.user
if request.hostname:
container_options["hostname"] = request.hostname
if request.network:
container_options["network"] = request.network
if request.mem_limit:
container_options["mem_limit"] = request.mem_limit
if request.cpu_period:
container_options["cpu_period"] = request.cpu_period
if request.cpu_quota:
container_options["cpu_quota"] = request.cpu_quota
try:
container = self.client.containers.create(**container_options)
except ImageNotFound:
raise DockerClientError(
f"Image '{request.image}' not found",
status_code=status.HTTP_404_NOT_FOUND,
)
except APIError as e:
raise DockerClientError(f"Failed to create container: {e}")
return ContainerCreateResponse(
id=container.id,
name=container.name,
warnings=[],
)
def inspect_container(self, container_id: str) -> ContainerResponse:
"""Get detailed information about a container."""
container = self.get_managed_container(container_id)
return self._container_to_response(container)
def start_container(self, container_id: str) -> None:
"""Start a container."""
container = self.get_managed_container(container_id)
try:
container.start()
except APIError as e:
raise DockerClientError(f"Failed to start container: {e}")
def stop_container(self, container_id: str, timeout: int = 10) -> None:
"""Stop a container."""
container = self.get_managed_container(container_id)
try:
container.stop(timeout=timeout)
except APIError as e:
raise DockerClientError(f"Failed to stop container: {e}")
def restart_container(self, container_id: str, timeout: int = 10) -> None:
"""Restart a container."""
container = self.get_managed_container(container_id)
try:
container.restart(timeout=timeout)
except APIError as e:
raise DockerClientError(f"Failed to restart container: {e}")
def remove_container(self, container_id: str, force: bool = False, v: bool = False) -> None:
"""Remove a container."""
container = self.get_managed_container(container_id)
try:
container.remove(force=force, v=v)
except APIError as e:
raise DockerClientError(f"Failed to remove container: {e}")
def get_logs(
self,
container_id: str,
stdout: bool = True,
stderr: bool = True,
tail: Union[int, str] = "all",
since: Optional[int] = None,
until: Optional[int] = None,
) -> ContainerLogsResponse:
"""Get container logs."""
container = self.get_managed_container(container_id)
try:
logs = container.logs(
stdout=stdout,
stderr=stderr,
tail=tail,
since=since,
until=until,
)
if isinstance(logs, bytes):
logs = logs.decode("utf-8", errors="replace")
except APIError as e:
raise DockerClientError(f"Failed to get logs: {e}")
return ContainerLogsResponse(
logs=logs,
container_id=container_id,
)
def get_stats(self, container_id: str, stream: bool = False) -> ContainerStatsResponse:
"""Get container stats."""
container = self.get_managed_container(container_id)
try:
stats = container.stats(stream=stream)
if isinstance(stats, Generator):
stats = next(stats)
except APIError as e:
raise DockerClientError(f"Failed to get stats: {e}")
# Parse CPU stats
cpu_delta = stats.get("cpu_stats", {}).get("cpu_usage", {}).get("total_usage", 0) - \
stats.get("precpu_stats", {}).get("cpu_usage", {}).get("total_usage", 0)
system_delta = stats.get("cpu_stats", {}).get("system_cpu_usage", 0) - \
stats.get("precpu_stats", {}).get("system_cpu_usage", 0)
num_cpus = len(stats.get("cpu_stats", {}).get("cpu_usage", {}).get("percpu_usage", [1]))
cpu_percent = 0.0
if system_delta > 0 and cpu_delta > 0:
cpu_percent = (cpu_delta / system_delta) * num_cpus * 100.0
# Parse memory stats
memory_stats = stats.get("memory_stats", {})
memory_usage = memory_stats.get("usage", 0)
memory_limit = memory_stats.get("limit", 1)
memory_percent = (memory_usage / memory_limit) * 100.0 if memory_limit > 0 else 0.0
# Parse network stats
networks = stats.get("networks", {})
network_rx = sum(n.get("rx_bytes", 0) for n in networks.values())
network_tx = sum(n.get("tx_bytes", 0) for n in networks.values())
return ContainerStatsResponse(
container_id=container_id,
cpu_percent=round(cpu_percent, 2),
memory_usage=memory_usage,
memory_limit=memory_limit,
memory_percent=round(memory_percent, 2),
network_rx_bytes=network_rx,
network_tx_bytes=network_tx,
)
def ping(self) -> bool:
"""Check if Docker daemon is accessible."""
try:
self.client.ping()
return True
except Exception:
return False
# Global instance (created on first import)
docker_manager: Optional[DockerManager] = None
def get_docker_manager() -> DockerManager:
"""Get or create the Docker manager instance."""
global docker_manager
if docker_manager is None:
docker_manager = DockerManager()
return docker_manager