initial commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user