291 lines
9.5 KiB
Python
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
|
||
|
|
)
|