letsbe-sysadmin/app/executors/docker_executor.py

291 lines
9.5 KiB
Python

"""Docker Compose executor for container management."""
import asyncio
import subprocess
import time
from pathlib import Path
from typing import Any
from app.config import get_settings
from app.executors.base import BaseExecutor, ExecutionResult
from app.utils.validation import ValidationError, validate_file_path
class DockerExecutor(BaseExecutor):
"""Execute Docker Compose operations with security controls.
Security measures:
- Directory validation against allowed stacks root
- Compose file existence verification
- Path traversal prevention
- Timeout enforcement on each subprocess
- No shell=True, command list only
Payload:
{
"compose_dir": "/opt/letsbe/stacks/myapp",
"pull": true # Optional, defaults to false
}
Result:
{
"compose_dir": "/opt/letsbe/stacks/myapp",
"compose_file": "/opt/letsbe/stacks/myapp/docker-compose.yml",
"pull_ran": true,
"logs": {
"pull": "<stdout+stderr>",
"up": "<stdout+stderr>"
}
}
"""
# Compose file search order
COMPOSE_FILE_NAMES = ["docker-compose.yml", "compose.yml"]
# Default timeout for each docker command (seconds)
DEFAULT_COMMAND_TIMEOUT = 300
@property
def task_type(self) -> str:
return "DOCKER_RELOAD"
async def execute(self, payload: dict[str, Any]) -> ExecutionResult:
"""Execute Docker Compose pull (optional) and up -d --remove-orphans.
Args:
payload: Must contain "compose_dir", optionally "pull" (bool) and "timeout"
Returns:
ExecutionResult with reload confirmation and logs
"""
self.validate_payload(payload, ["compose_dir"])
settings = get_settings()
compose_dir = payload["compose_dir"]
pull = payload.get("pull", False)
timeout = payload.get("timeout", self.DEFAULT_COMMAND_TIMEOUT)
# Validate compose directory is under allowed stacks root
try:
validated_dir = validate_file_path(
compose_dir,
settings.allowed_stacks_root,
must_exist=True,
)
except ValidationError as e:
self.logger.warning("docker_dir_validation_failed", path=compose_dir, error=str(e))
return ExecutionResult(
success=False,
data={},
error=f"Directory validation failed: {e}",
)
# Verify it's actually a directory
if not validated_dir.is_dir():
self.logger.warning("docker_not_directory", path=compose_dir)
return ExecutionResult(
success=False,
data={},
error=f"Path is not a directory: {compose_dir}",
)
# Find compose file in order of preference
compose_file = self._find_compose_file(validated_dir)
if compose_file is None:
self.logger.warning("docker_compose_not_found", dir=compose_dir)
return ExecutionResult(
success=False,
data={},
error=f"No compose file found in {compose_dir}. "
f"Looked for: {', '.join(self.COMPOSE_FILE_NAMES)}",
)
self.logger.info(
"docker_reloading",
compose_dir=str(validated_dir),
compose_file=str(compose_file),
pull=pull,
)
start_time = time.time()
logs: dict[str, str] = {}
pull_ran = False
try:
# Run pull if requested
if pull:
pull_ran = True
exit_code, stdout, stderr = await self._run_compose_command(
compose_file,
validated_dir,
["pull"],
timeout,
)
logs["pull"] = self._combine_output(stdout, stderr)
if exit_code != 0:
duration_ms = (time.time() - start_time) * 1000
self.logger.warning(
"docker_pull_failed",
compose_dir=str(validated_dir),
exit_code=exit_code,
stderr=stderr[:500] if stderr else None,
)
return ExecutionResult(
success=False,
data={
"compose_dir": str(validated_dir),
"compose_file": str(compose_file),
"pull_ran": pull_ran,
"logs": logs,
},
error=f"Docker pull failed with exit code {exit_code}",
duration_ms=duration_ms,
)
# Run up -d --remove-orphans
exit_code, stdout, stderr = await self._run_compose_command(
compose_file,
validated_dir,
["up", "-d", "--remove-orphans"],
timeout,
)
logs["up"] = self._combine_output(stdout, stderr)
duration_ms = (time.time() - start_time) * 1000
success = exit_code == 0
if success:
self.logger.info(
"docker_reloaded",
compose_dir=str(validated_dir),
exit_code=exit_code,
duration_ms=duration_ms,
)
else:
self.logger.warning(
"docker_reload_failed",
compose_dir=str(validated_dir),
exit_code=exit_code,
stderr=stderr[:500] if stderr else None,
)
return ExecutionResult(
success=success,
data={
"compose_dir": str(validated_dir),
"compose_file": str(compose_file),
"pull_ran": pull_ran,
"logs": logs,
},
error=f"Docker up failed with exit code {exit_code}" if not success else None,
duration_ms=duration_ms,
)
except asyncio.TimeoutError:
duration_ms = (time.time() - start_time) * 1000
self.logger.error("docker_timeout", compose_dir=str(validated_dir), timeout=timeout)
return ExecutionResult(
success=False,
data={
"compose_dir": str(validated_dir),
"compose_file": str(compose_file),
"pull_ran": pull_ran,
"logs": logs,
},
error=f"Docker operation timed out after {timeout} seconds",
duration_ms=duration_ms,
)
except Exception as e:
duration_ms = (time.time() - start_time) * 1000
self.logger.error("docker_error", compose_dir=str(validated_dir), error=str(e))
return ExecutionResult(
success=False,
data={
"compose_dir": str(validated_dir),
"compose_file": str(compose_file),
"pull_ran": pull_ran,
"logs": logs,
},
error=str(e),
duration_ms=duration_ms,
)
def _find_compose_file(self, compose_dir: Path) -> Path | None:
"""Find compose file in the directory.
Searches in order: docker-compose.yml, compose.yml
Args:
compose_dir: Directory to search in
Returns:
Path to compose file, or None if not found
"""
for filename in self.COMPOSE_FILE_NAMES:
compose_file = compose_dir / filename
if compose_file.exists():
return compose_file
return None
def _combine_output(self, stdout: str, stderr: str) -> str:
"""Combine stdout and stderr into a single string.
Args:
stdout: Standard output
stderr: Standard error
Returns:
Combined output string
"""
parts = []
if stdout:
parts.append(stdout)
if stderr:
parts.append(stderr)
return "\n".join(parts)
async def _run_compose_command(
self,
compose_file: Path,
compose_dir: Path,
args: list[str],
timeout: int,
) -> tuple[int, str, str]:
"""Run a docker compose command.
Args:
compose_file: Path to compose file
compose_dir: Working directory
args: Additional arguments after 'docker compose -f <file>'
timeout: Operation timeout in seconds
Returns:
Tuple of (exit_code, stdout, stderr)
"""
def _run() -> tuple[int, str, str]:
# Build command: docker compose -f <file> <args>
cmd = [
"docker",
"compose",
"-f",
str(compose_file),
] + args
# Run command from compose directory, no shell=True
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
cwd=str(compose_dir),
)
return result.returncode, result.stdout, result.stderr
return await asyncio.wait_for(
asyncio.to_thread(_run),
timeout=timeout + 30, # Watchdog with buffer
)