"""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": "", "up": "" } } """ # 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 ' timeout: Operation timeout in seconds Returns: Tuple of (exit_code, stdout, stderr) """ def _run() -> tuple[int, str, str]: # Build command: docker compose -f 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 )