Merakit-Deploy/wordpress/wordpress_deployer/docker_manager.py

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