277 lines
7.5 KiB
Python
277 lines
7.5 KiB
Python
"""
|
|
Docker management module
|
|
|
|
Wrapper for Docker Compose operations with validation and error handling
|
|
"""
|
|
|
|
import logging
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import List
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class DockerError(Exception):
|
|
"""Raised when Docker operations fail"""
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class ContainerInfo:
|
|
"""Information about a running container"""
|
|
container_id: str
|
|
name: str
|
|
status: str
|
|
|
|
|
|
class DockerManager:
|
|
"""Docker Compose operations wrapper"""
|
|
|
|
def __init__(self, compose_file: Path, env_file: Path):
|
|
"""
|
|
Initialize Docker manager
|
|
|
|
Args:
|
|
compose_file: Path to docker-compose.yml
|
|
env_file: Path to .env file
|
|
"""
|
|
self._compose_file = compose_file
|
|
self._env_file = env_file
|
|
self._logger = logging.getLogger(f"{__name__}.DockerManager")
|
|
|
|
def _run_command(
|
|
self,
|
|
cmd: List[str],
|
|
check: bool = True,
|
|
capture_output: bool = True
|
|
) -> subprocess.CompletedProcess:
|
|
"""
|
|
Run docker compose command
|
|
|
|
Args:
|
|
cmd: Command list to execute
|
|
check: Whether to raise on non-zero exit
|
|
capture_output: Whether to capture stdout/stderr
|
|
|
|
Returns:
|
|
CompletedProcess instance
|
|
|
|
Raises:
|
|
DockerError: If command fails and check=True
|
|
"""
|
|
self._logger.debug(f"Running: {' '.join(cmd)}")
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
cmd,
|
|
check=check,
|
|
capture_output=capture_output,
|
|
text=True,
|
|
cwd=self._compose_file.parent
|
|
)
|
|
return result
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
error_msg = f"Docker command failed: {e.stderr or e.stdout or str(e)}"
|
|
self._logger.error(error_msg)
|
|
raise DockerError(error_msg) from e
|
|
except FileNotFoundError as e:
|
|
raise DockerError(
|
|
f"Docker command not found. Is Docker installed? {e}"
|
|
) from e
|
|
|
|
def validate_compose_file(self) -> None:
|
|
"""
|
|
Validate docker-compose.yml syntax
|
|
|
|
Raises:
|
|
DockerError: If compose file is invalid
|
|
"""
|
|
self._logger.debug("Validating docker-compose.yml")
|
|
|
|
cmd = [
|
|
"docker", "compose",
|
|
"-f", str(self._compose_file),
|
|
"--env-file", str(self._env_file),
|
|
"config", "--quiet"
|
|
]
|
|
|
|
try:
|
|
self._run_command(cmd)
|
|
self._logger.debug("docker-compose.yml is valid")
|
|
|
|
except DockerError as e:
|
|
raise DockerError(f"Invalid docker-compose.yml: {e}") from e
|
|
|
|
def pull_images(self, dry_run: bool = False) -> None:
|
|
"""
|
|
Pull required Docker images
|
|
|
|
Args:
|
|
dry_run: If True, only log what would be done
|
|
|
|
Raises:
|
|
DockerError: If pull fails
|
|
"""
|
|
if dry_run:
|
|
self._logger.info("[DRY-RUN] Would pull Docker images")
|
|
return
|
|
|
|
self._logger.info("Pulling Docker images")
|
|
|
|
cmd = [
|
|
"docker", "compose",
|
|
"-f", str(self._compose_file),
|
|
"--env-file", str(self._env_file),
|
|
"pull"
|
|
]
|
|
|
|
self._run_command(cmd)
|
|
self._logger.info("Docker images pulled successfully")
|
|
|
|
def start_services(self, dry_run: bool = False) -> List[ContainerInfo]:
|
|
"""
|
|
Start Docker Compose services
|
|
|
|
Args:
|
|
dry_run: If True, only log what would be done
|
|
|
|
Returns:
|
|
List of created containers for rollback
|
|
|
|
Raises:
|
|
DockerError: If start fails
|
|
"""
|
|
if dry_run:
|
|
self._logger.info("[DRY-RUN] Would start Docker services")
|
|
return []
|
|
|
|
self._logger.info("Starting Docker services")
|
|
|
|
cmd = [
|
|
"docker", "compose",
|
|
"-f", str(self._compose_file),
|
|
"--env-file", str(self._env_file),
|
|
"up", "-d"
|
|
]
|
|
|
|
self._run_command(cmd)
|
|
|
|
# Get container info for rollback
|
|
containers = self.get_container_status()
|
|
|
|
self._logger.info(
|
|
f"Docker services started successfully: {len(containers)} containers"
|
|
)
|
|
|
|
return containers
|
|
|
|
def stop_services(self, dry_run: bool = False) -> None:
|
|
"""
|
|
Stop Docker Compose services
|
|
|
|
Args:
|
|
dry_run: If True, only log what would be done
|
|
|
|
Raises:
|
|
DockerError: If stop fails
|
|
"""
|
|
if dry_run:
|
|
self._logger.info("[DRY-RUN] Would stop Docker services")
|
|
return
|
|
|
|
self._logger.info("Stopping Docker services")
|
|
|
|
cmd = [
|
|
"docker", "compose",
|
|
"-f", str(self._compose_file),
|
|
"--env-file", str(self._env_file),
|
|
"down"
|
|
]
|
|
|
|
self._run_command(cmd)
|
|
self._logger.info("Docker services stopped successfully")
|
|
|
|
def stop_services_and_remove_volumes(self, dry_run: bool = False) -> None:
|
|
"""
|
|
Stop services and remove volumes (full cleanup)
|
|
|
|
Args:
|
|
dry_run: If True, only log what would be done
|
|
|
|
Raises:
|
|
DockerError: If stop fails
|
|
"""
|
|
if dry_run:
|
|
self._logger.info("[DRY-RUN] Would stop Docker services and remove volumes")
|
|
return
|
|
|
|
self._logger.info("Stopping Docker services and removing volumes")
|
|
|
|
cmd = [
|
|
"docker", "compose",
|
|
"-f", str(self._compose_file),
|
|
"--env-file", str(self._env_file),
|
|
"down", "-v"
|
|
]
|
|
|
|
self._run_command(cmd)
|
|
self._logger.info("Docker services stopped and volumes removed")
|
|
|
|
def get_container_status(self) -> List[ContainerInfo]:
|
|
"""
|
|
Get status of containers for this project
|
|
|
|
Returns:
|
|
List of ContainerInfo objects
|
|
|
|
Raises:
|
|
DockerError: If status check fails
|
|
"""
|
|
self._logger.debug("Getting container status")
|
|
|
|
cmd = [
|
|
"docker", "compose",
|
|
"-f", str(self._compose_file),
|
|
"--env-file", str(self._env_file),
|
|
"ps", "-q"
|
|
]
|
|
|
|
result = self._run_command(cmd)
|
|
|
|
container_ids = [
|
|
cid.strip()
|
|
for cid in result.stdout.strip().split('\n')
|
|
if cid.strip()
|
|
]
|
|
|
|
containers = []
|
|
for container_id in container_ids:
|
|
# Get container details
|
|
inspect_cmd = ["docker", "inspect", container_id, "--format", "{{.Name}}:{{.State.Status}}"]
|
|
try:
|
|
inspect_result = self._run_command(inspect_cmd)
|
|
name_status = inspect_result.stdout.strip()
|
|
if ':' in name_status:
|
|
name, status = name_status.split(':', 1)
|
|
# Remove leading slash from container name
|
|
name = name.lstrip('/')
|
|
containers.append(ContainerInfo(
|
|
container_id=container_id,
|
|
name=name,
|
|
status=status
|
|
))
|
|
except DockerError:
|
|
# If inspect fails, just record the ID
|
|
containers.append(ContainerInfo(
|
|
container_id=container_id,
|
|
name="unknown",
|
|
status="unknown"
|
|
))
|
|
|
|
self._logger.debug(f"Found {len(containers)} containers")
|
|
return containers
|