"""Docker service for managing tool instances.""" import os import subprocess from pathlib import Path from typing import Any def render_compose_template(template: str, variables: dict[str, Any]) -> str: """Render a Docker Compose template with variable substitution. Args: template: The compose template string variables: Dictionary of variable names to values Returns: Rendered compose file content """ result = template for key, value in variables.items(): placeholder = f"{{{{{key}}}}}" result = result.replace(placeholder, str(value)) return result def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str: """Create and return the instance directory path. Args: instance_id: Unique instance identifier base_path: Base directory for all instances (defaults to Settings.instance_base_path) Returns: Absolute path to instance directory """ if base_path is None: from src.config import Settings base_path = Settings().instance_base_path instance_dir = Path(base_path) / instance_id instance_dir.mkdir(parents=True, exist_ok=True) return str(instance_dir.absolute()) def write_compose_file(instance_dir: str, content: str) -> str: """Write the rendered compose file to the instance directory. Args: instance_dir: Path to instance directory content: Rendered compose content Returns: Path to the compose file """ compose_path = Path(instance_dir) / "docker-compose.yml" compose_path.write_text(content) return str(compose_path) def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str: """Write environment variables to a .env file. Args: instance_dir: Path to instance directory env_vars: Dictionary of env var names to values Returns: Path to the env file """ env_path = Path(instance_dir) / ".env" lines = [f'{key}="{value}"' for key, value in env_vars.items()] env_path.write_text("\n".join(lines) + "\n") return str(env_path) def write_config_files(instance_dir: str, files: dict[str, str]) -> None: """Write config files to the instance directory. Args: instance_dir: Path to instance directory files: Dictionary of file paths (relative to instance dir) to content """ instance_path = Path(instance_dir) for file_path, content in files.items(): # Ensure the path is within the instance directory (security) full_path = instance_path / file_path try: full_path.resolve().relative_to(instance_path.resolve()) except ValueError: raise ValueError(f"File path '{file_path}' escapes instance directory") full_path.parent.mkdir(parents=True, exist_ok=True) full_path.write_text(content) def execute_compose_command( compose_path: str, action: str, timeout: int = 60, env_file: str | None = None ) -> tuple[int, str, str]: """Execute a docker compose command. Args: compose_path: Path to docker-compose.yml action: The compose action (up, down, start, stop, restart) timeout: Command timeout in seconds env_file: Optional path to .env file for environment variables Returns: Tuple of (returncode, stdout, stderr) """ instance_dir = Path(compose_path).parent cmd = ["docker", "compose", "-f", compose_path] if env_file: cmd.extend(["--env-file", env_file]) if action == "up": cmd.extend(["up", "-d"]) elif action == "down": cmd.extend(["down", "-v"]) elif action in ("start", "stop", "restart"): cmd.append(action) else: raise ValueError(f"Unknown compose action: {action}") result = subprocess.run( cmd, cwd=str(instance_dir), capture_output=True, text=True, timeout=timeout, ) return result.returncode, result.stdout, result.stderr def get_container_id(instance_name: str) -> str | None: """Get the container ID for a compose service. Args: instance_name: The service name in compose Returns: Container ID or None if not found """ result = subprocess.run( ["docker", "ps", "-q", "--filter", f"name={instance_name}"], capture_output=True, text=True, ) if result.returncode == 0 and result.stdout.strip(): return result.stdout.strip().split("\n")[0] return None def get_container_name(instance_name: str) -> str | None: """Get the full container name for a compose service. Args: instance_name: The service name in compose Returns: Container name or None if not found """ result = subprocess.run( ["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"], capture_output=True, text=True, ) if result.returncode == 0 and result.stdout.strip(): return result.stdout.strip().split("\n")[0] return None def get_container_status(container_id: str) -> str: """Get the status of a Docker container. Args: container_id: Docker container ID Returns: Container status string (running, exited, etc.) """ result = subprocess.run( ["docker", "inspect", "-f", "{{.State.Status}}", container_id], capture_output=True, text=True, ) if result.returncode == 0: return result.stdout.strip() return "unknown" def get_container_logs(container_id: str, tail: int = 100) -> str: """Get the logs of a Docker container. Args: container_id: Docker container ID tail: Number of lines to return Returns: Container logs """ result = subprocess.run( ["docker", "logs", "--tail", str(tail), container_id], capture_output=True, text=True, ) if result.returncode == 0: return result.stdout return f"Failed to get logs: {result.stderr}" def find_free_port(start: int = 10000, end: int = 20000) -> int: """Find a free TCP port in the given range. Args: start: Start of port range end: End of port range Returns: Free port number """ import socket for port in range(start, end): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: if s.connect_ex(("localhost", port)) != 0: return port raise RuntimeError(f"No free port found in range {start}-{end}")