Spaces:
Running
Running
| # SPDX-License-Identifier: BSD-3-Clause | |
| """ | |
| Container provider abstractions for running environment servers. | |
| This module provides a pluggable architecture for different container providers | |
| (local Docker, Kubernetes, cloud providers, etc.) to be used with EnvClient. | |
| """ | |
| from __future__ import annotations | |
| from abc import ABC, abstractmethod | |
| from typing import Any, Dict, Optional, Sequence, TypeVar | |
| _ContainerProviderT = TypeVar("_ContainerProviderT", bound="ContainerProvider") | |
| class ContainerProvider(ABC): | |
| """ | |
| Abstract base class for container providers. | |
| Providers implement this interface to support different container platforms: | |
| - LocalDockerProvider: Runs containers on local Docker daemon | |
| - KubernetesProvider: Runs containers in Kubernetes cluster | |
| - FargateProvider: Runs containers on AWS Fargate | |
| - CloudRunProvider: Runs containers on Google Cloud Run | |
| The provider manages a single container lifecycle and provides the base URL | |
| for connecting to it. | |
| Examples: | |
| ```python | |
| provider = LocalDockerProvider() | |
| base_url = provider.start_container("echo-env:latest") | |
| print(base_url) # http://localhost:8000 | |
| # Use the environment via base_url | |
| provider.stop_container() | |
| ``` | |
| """ | |
| def start_container( | |
| self, | |
| image: str, | |
| port: Optional[int] = None, | |
| env_vars: Optional[Dict[str, str]] = None, | |
| **kwargs: Any, | |
| ) -> str: | |
| """ | |
| Start a container from the specified image. | |
| Args: | |
| image (`str`): | |
| Provider-specific container *source* identifier. For | |
| container-based providers this is a registry image name (e.g. | |
| `"echo-env:latest"`); other providers may map it to a | |
| provider-specific source (see the provider's documentation). | |
| port (`int`, *optional*): | |
| Port to expose. If `None`, the provider chooses. | |
| env_vars (`dict`, *optional*): | |
| Environment variables to pass to container. | |
| **kwargs: | |
| Provider-specific options. | |
| Returns: | |
| `str`: Base URL to connect to the container (e.g., `"http://localhost:8000"`). | |
| Raises: | |
| RuntimeError: If container fails to start. | |
| """ | |
| pass | |
| def stop_container(self) -> None: | |
| """ | |
| Stop and remove the running container. | |
| This cleans up the container that was started by start_container(). | |
| """ | |
| pass | |
| def wait_for_ready(self, base_url: str, timeout_s: float = 30.0) -> None: | |
| """ | |
| Wait for the container to be ready to accept requests. | |
| This typically polls the /health endpoint until it returns 200. | |
| Args: | |
| base_url (`str`): | |
| Base URL of the container. | |
| timeout_s (`float`, *optional*, defaults to `30.0`): | |
| Maximum time to wait in seconds. | |
| Raises: | |
| TimeoutError: If container doesn't become ready in time. | |
| """ | |
| pass | |
| def close(self) -> None: | |
| """ | |
| Release provider-held resources (e.g. SDK clients, connections). | |
| Defaults to a no-op so existing providers are unaffected. Providers that | |
| hold external resources beyond the container itself (such as a cloud SDK | |
| client) override this to release them; it is also invoked on context- | |
| manager exit. Lightweight providers need not override it. | |
| """ | |
| pass | |
| def __enter__(self: _ContainerProviderT) -> _ContainerProviderT: | |
| return self | |
| def __exit__(self, exc_type, exc, tb) -> Optional[bool]: | |
| self.close() | |
| return None | |
| class LocalDockerProvider(ContainerProvider): | |
| """ | |
| Container provider for local Docker daemon. | |
| This provider runs containers on the local machine using Docker. | |
| Useful for development and testing. | |
| Examples: | |
| ```python | |
| provider = LocalDockerProvider() | |
| base_url = provider.start_container("echo-env:latest") | |
| # Container running on http://localhost:<random-port> | |
| provider.stop_container() | |
| ``` | |
| """ | |
| def __init__(self): | |
| """Initialize the local Docker provider.""" | |
| self._container_id: Optional[str] = None | |
| self._container_name: Optional[str] = None | |
| # Check if Docker is available | |
| import subprocess | |
| try: | |
| subprocess.run( | |
| ["docker", "version"], | |
| check=True, | |
| capture_output=True, | |
| timeout=5, | |
| ) | |
| except ( | |
| subprocess.CalledProcessError, | |
| FileNotFoundError, | |
| subprocess.TimeoutExpired, | |
| ): | |
| raise RuntimeError( | |
| "Docker is not available. Please install Docker Desktop or Docker Engine." | |
| ) | |
| def start_container( | |
| self, | |
| image: str, | |
| port: Optional[int] = None, | |
| env_vars: Optional[Dict[str, str]] = None, | |
| **kwargs: Any, | |
| ) -> str: | |
| """ | |
| Start a Docker container locally. | |
| Args: | |
| image (`str`): | |
| Docker image name. | |
| port (`int`, *optional*): | |
| Port to expose. If `None`, finds an available port. | |
| env_vars (`dict`, *optional*): | |
| Environment variables for the container. | |
| **kwargs: | |
| Additional Docker run options. | |
| Returns: | |
| `str`: Base URL to connect to the container. | |
| """ | |
| import subprocess | |
| import time | |
| # Find available port if not specified | |
| if port is None: | |
| port = self._find_available_port() | |
| # Generate container name | |
| self._container_name = self._generate_container_name(image) | |
| # Build docker run command | |
| cmd = [ | |
| "docker", | |
| "run", | |
| "-d", # Detached | |
| "--name", | |
| self._container_name, | |
| "-p", | |
| f"{port}:8000", # Map port | |
| ] | |
| # Add environment variables | |
| if env_vars: | |
| for key, value in env_vars.items(): | |
| cmd.extend(["-e", f"{key}={value}"]) | |
| # Add image | |
| cmd.append(image) | |
| # Run container | |
| try: | |
| result = subprocess.run(cmd, capture_output=True, text=True, check=True) | |
| self._container_id = result.stdout.strip() | |
| except subprocess.CalledProcessError as e: | |
| error_msg = f"Failed to start Docker container.\nCommand: {' '.join(cmd)}\nExit code: {e.returncode}\nStderr: {e.stderr}\nStdout: {e.stdout}" | |
| raise RuntimeError(error_msg) from e | |
| # Wait a moment for container to start | |
| time.sleep(1) | |
| base_url = f"http://localhost:{port}" | |
| return base_url | |
| def stop_container(self) -> None: | |
| """ | |
| Stop and remove the Docker container. | |
| """ | |
| if self._container_id is None: | |
| return | |
| import subprocess | |
| try: | |
| # Stop container | |
| subprocess.run( | |
| ["docker", "stop", self._container_id], | |
| capture_output=True, | |
| check=True, | |
| timeout=10, | |
| ) | |
| # Remove container | |
| subprocess.run( | |
| ["docker", "rm", self._container_id], | |
| capture_output=True, | |
| check=True, | |
| timeout=10, | |
| ) | |
| except subprocess.CalledProcessError: | |
| # Container might already be stopped/removed | |
| pass | |
| finally: | |
| self._container_id = None | |
| self._container_name = None | |
| def wait_for_ready(self, base_url: str, timeout_s: float = 30.0) -> None: | |
| """ | |
| Wait for container to be ready by polling /health endpoint. | |
| Args: | |
| base_url (`str`): | |
| Base URL of the container. | |
| timeout_s (`float`, *optional*, defaults to `30.0`): | |
| Maximum time to wait in seconds. | |
| Raises: | |
| TimeoutError: If container doesn't become ready. | |
| """ | |
| import time | |
| import requests | |
| start_time = time.time() | |
| health_url = f"{base_url}/health" | |
| # Bypass proxy for localhost to avoid proxy issues | |
| proxies = {"http": None, "https": None} | |
| while time.time() - start_time < timeout_s: | |
| try: | |
| response = requests.get(health_url, timeout=2.0, proxies=proxies) | |
| if response.status_code == 200: | |
| return | |
| except requests.RequestException: | |
| pass | |
| time.sleep(0.5) | |
| raise TimeoutError( | |
| f"Container at {base_url} did not become ready within {timeout_s}s" | |
| ) | |
| def _find_available_port(self) -> int: | |
| """ | |
| Find an available port on localhost. | |
| Returns: | |
| `int`: An available port number. | |
| """ | |
| import socket | |
| with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: | |
| s.bind(("", 0)) | |
| s.listen(1) | |
| port = s.getsockname()[1] | |
| return port | |
| def _generate_container_name(self, image: str) -> str: | |
| """ | |
| Generate a unique container name based on image name and timestamp. | |
| Args: | |
| image (`str`): | |
| Docker image name. | |
| Returns: | |
| `str`: A unique container name. | |
| """ | |
| import time | |
| clean_image = image.split("/")[-1].split(":")[0] | |
| timestamp = int(time.time() * 1000) | |
| return f"{clean_image}-{timestamp}" | |
| class DockerSwarmProvider(ContainerProvider): | |
| """ | |
| Container provider that uses Docker Swarm services for local concurrency. | |
| This provider creates a replicated Swarm service backed by the local Docker | |
| engine. The built-in load-balancer fans requests across the replicas, | |
| allowing multiple container instances to run concurrently on the developer | |
| workstation (mirroring the workflow described in the Docker stack docs). | |
| """ | |
| def __init__( | |
| self, | |
| *, | |
| auto_init_swarm: bool = True, | |
| overlay_network: Optional[str] = None, | |
| ): | |
| """ | |
| Args: | |
| auto_init_swarm (`bool`, *optional*, defaults to `True`): | |
| Whether to call `docker swarm init` when Swarm is not active. | |
| Otherwise, the user must manually initialize Swarm. | |
| overlay_network (`str`, *optional*): | |
| Overlay network name for the service. When provided, the network | |
| is created with `docker network create --driver overlay --attachable` | |
| if it does not already exist. | |
| """ | |
| self._service_name: Optional[str] = None | |
| self._service_id: Optional[str] = None | |
| self._published_port: Optional[int] = None | |
| self._overlay_network = overlay_network | |
| self._auto_init_swarm = auto_init_swarm | |
| self._ensure_docker_available() | |
| self._ensure_swarm_initialized() | |
| if self._overlay_network: | |
| self._ensure_overlay_network(self._overlay_network) | |
| def start_container( | |
| self, | |
| image: str, | |
| port: Optional[int] = None, | |
| env_vars: Optional[Dict[str, str]] = None, | |
| **kwargs: Any, | |
| ) -> str: | |
| """ | |
| Start (or scale) a Swarm service for the given image. | |
| Args: | |
| image (`str`): | |
| Docker image name. | |
| port (`int`, *optional*): | |
| Port to expose. If `None`, finds an available port. | |
| env_vars (`dict`, *optional*): | |
| Environment variables for the container. | |
| replicas (`int`, *optional*, defaults to `2`): | |
| Number of container replicas. | |
| cpu_limit (`float` or `str`, *optional*): | |
| CPU limit passed to `--limit-cpu`. | |
| memory_limit (`str`, *optional*): | |
| Memory limit passed to `--limit-memory`. | |
| constraints (`Sequence[str]`, *optional*): | |
| Placement constraints. | |
| labels (`dict`, *optional*): | |
| Service labels. | |
| command (`Sequence[str]` or `str`, *optional*): | |
| Override container command. | |
| Returns: | |
| `str`: Base URL to connect to the service. | |
| """ | |
| import shlex | |
| import subprocess | |
| import time | |
| allowed_kwargs = { | |
| "replicas", | |
| "cpu_limit", | |
| "memory_limit", | |
| "constraints", | |
| "labels", | |
| "command", | |
| } | |
| unknown = set(kwargs) - allowed_kwargs | |
| if unknown: | |
| raise ValueError(f"Unsupported kwargs for DockerSwarmProvider: {unknown}") | |
| replicas = int(kwargs.get("replicas", 2)) | |
| cpu_limit = kwargs.get("cpu_limit") | |
| memory_limit = kwargs.get("memory_limit") | |
| constraints: Optional[Sequence[str]] = kwargs.get("constraints") | |
| labels: Optional[Dict[str, str]] = kwargs.get("labels") | |
| command_override = kwargs.get("command") | |
| if port is None: | |
| port = self._find_available_port() | |
| self._service_name = self._generate_service_name(image) | |
| self._published_port = port | |
| cmd = [ | |
| "docker", | |
| "service", | |
| "create", | |
| "--detach", | |
| "--name", | |
| self._service_name, | |
| "--replicas", | |
| str(max(1, replicas)), | |
| "--publish", | |
| f"{port}:8000", | |
| ] | |
| if self._overlay_network: | |
| cmd.extend(["--network", self._overlay_network]) | |
| if env_vars: | |
| for key, value in env_vars.items(): | |
| cmd.extend(["--env", f"{key}={value}"]) | |
| if cpu_limit is not None: | |
| cmd.extend(["--limit-cpu", str(cpu_limit)]) | |
| if memory_limit is not None: | |
| cmd.extend(["--limit-memory", str(memory_limit)]) | |
| if constraints: | |
| for constraint in constraints: | |
| cmd.extend(["--constraint", constraint]) | |
| if labels: | |
| for key, value in labels.items(): | |
| cmd.extend(["--label", f"{key}={value}"]) | |
| cmd.append(image) | |
| if command_override: | |
| if isinstance(command_override, str): | |
| cmd.extend(shlex.split(command_override)) | |
| else: | |
| cmd.extend(command_override) | |
| try: | |
| result = subprocess.run( | |
| cmd, | |
| capture_output=True, | |
| text=True, | |
| check=True, | |
| ) | |
| self._service_id = result.stdout.strip() | |
| except subprocess.CalledProcessError as e: | |
| error_msg = ( | |
| "Failed to start Docker Swarm service.\n" | |
| f"Command: {' '.join(cmd)}\n" | |
| f"Exit code: {e.returncode}\n" | |
| f"Stdout: {e.stdout}\n" | |
| f"Stderr: {e.stderr}" | |
| ) | |
| raise RuntimeError(error_msg) from e | |
| # Give Swarm a brief moment to schedule the tasks. | |
| time.sleep(1.0) | |
| return f"http://localhost:{port}" | |
| def stop_container(self) -> None: | |
| """ | |
| Remove the Swarm service (and keep the Swarm manager running). | |
| """ | |
| if not self._service_name: | |
| return | |
| import subprocess | |
| try: | |
| subprocess.run( | |
| ["docker", "service", "rm", self._service_name], | |
| capture_output=True, | |
| check=True, | |
| timeout=10, | |
| ) | |
| except subprocess.CalledProcessError: | |
| # Service may already be gone; ignore. | |
| pass | |
| finally: | |
| self._service_name = None | |
| self._service_id = None | |
| self._published_port = None | |
| def wait_for_ready(self, base_url: str, timeout_s: float = 30.0) -> None: | |
| """ | |
| Wait for at least one replica to become healthy by polling /health. | |
| With Swarm's load balancer, requests round-robin across replicas, | |
| so this only verifies that at least one replica is responding. Some | |
| replicas may still be starting when this returns. | |
| """ | |
| import time | |
| import requests | |
| deadline = time.time() + timeout_s | |
| health_url = f"{base_url}/health" | |
| # Bypass proxy for localhost to avoid proxy issues | |
| proxies = {"http": None, "https": None} | |
| while time.time() < deadline: | |
| try: | |
| response = requests.get(health_url, timeout=2.0, proxies=proxies) | |
| if response.status_code == 200: | |
| return | |
| except requests.RequestException: | |
| pass | |
| time.sleep(0.5) | |
| raise TimeoutError( | |
| f"Swarm service at {base_url} did not become ready within {timeout_s}s" | |
| ) | |
| def _ensure_docker_available(self) -> None: | |
| import subprocess | |
| try: | |
| subprocess.run( | |
| ["docker", "version"], | |
| check=True, | |
| capture_output=True, | |
| timeout=5, | |
| ) | |
| except ( | |
| subprocess.CalledProcessError, | |
| FileNotFoundError, | |
| subprocess.TimeoutExpired, | |
| ) as exc: | |
| raise RuntimeError( | |
| "Docker is not available. Please install Docker Desktop or Docker Engine." | |
| ) from exc | |
| def _ensure_swarm_initialized(self) -> None: | |
| import subprocess | |
| try: | |
| result = subprocess.run( | |
| ["docker", "info", "--format", "{{.Swarm.LocalNodeState}}"], | |
| capture_output=True, | |
| text=True, | |
| check=True, | |
| timeout=5, | |
| ) | |
| state = result.stdout.strip().lower() | |
| if state == "active": | |
| return | |
| except subprocess.CalledProcessError: | |
| state = "unknown" | |
| if not self._auto_init_swarm: | |
| raise RuntimeError( | |
| f"Docker Swarm is not active (state={state}). Enable Swarm manually or pass auto_init_swarm=True." | |
| ) | |
| try: | |
| subprocess.run( | |
| ["docker", "swarm", "init"], | |
| check=True, | |
| capture_output=True, | |
| timeout=10, | |
| ) | |
| except subprocess.CalledProcessError as e: | |
| raise RuntimeError("Failed to initialize Docker Swarm") from e | |
| def _ensure_overlay_network(self, network: str) -> None: | |
| import subprocess | |
| inspect = subprocess.run( | |
| ["docker", "network", "inspect", network], | |
| capture_output=True, | |
| text=True, | |
| check=False, | |
| ) | |
| if inspect.returncode == 0: | |
| return | |
| try: | |
| subprocess.run( | |
| [ | |
| "docker", | |
| "network", | |
| "create", | |
| "--driver", | |
| "overlay", | |
| "--attachable", | |
| network, | |
| ], | |
| check=True, | |
| capture_output=True, | |
| timeout=10, | |
| ) | |
| except subprocess.CalledProcessError as e: | |
| raise RuntimeError(f"Failed to create overlay network '{network}'") from e | |
| def _find_available_port(self) -> int: | |
| import socket | |
| with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: | |
| s.bind(("", 0)) | |
| s.listen(1) | |
| port = s.getsockname()[1] | |
| return port | |
| def _generate_service_name(self, image: str) -> str: | |
| import time | |
| clean_image = image.split("/")[-1].split(":")[0] | |
| timestamp = int(time.time() * 1000) | |
| return f"{clean_image}-swarm-{timestamp}" | |
| class KubernetesProvider(ContainerProvider): | |
| """ | |
| Planned container provider for Kubernetes clusters. | |
| Not yet implemented: this is a placeholder for the planned Kubernetes | |
| backend and does not implement the abstract `ContainerProvider` methods, so | |
| it cannot be instantiated. Use `LocalDockerProvider`, `DockerSwarmProvider`, | |
| `DaytonaProvider`, or `ACASandboxProvider` instead. | |
| """ | |
| pass | |
| class RuntimeProvider(ABC): | |
| """ | |
| Abstract base class for runtime providers that are not container providers. | |
| Providers implement this interface to support different runtime platforms: | |
| - UVProvider: Runs environments via `uv run` | |
| The provider manages a single runtime lifecycle and provides the base URL | |
| for connecting to it. | |
| Examples: | |
| ```python | |
| provider = UVProvider(project_path="/path/to/env") | |
| base_url = provider.start() | |
| print(base_url) # http://localhost:8000 | |
| provider.stop() | |
| ``` | |
| """ | |
| def start( | |
| self, | |
| port: Optional[int] = None, | |
| env_vars: Optional[Dict[str, str]] = None, | |
| **kwargs: Any, | |
| ) -> str: | |
| """ | |
| Start the runtime. | |
| Args: | |
| port (`int`, *optional*): | |
| Port to expose. If `None`, the provider chooses. | |
| env_vars (`dict`, *optional*): | |
| Environment variables for the runtime. | |
| **kwargs: | |
| Additional runtime options. | |
| Returns: | |
| `str`: Base URL to connect to the runtime. | |
| """ | |
| def stop(self) -> None: | |
| """ | |
| Stop the runtime. | |
| """ | |
| pass | |
| def wait_for_ready(self, timeout_s: float = 30.0) -> None: | |
| """ | |
| Wait for the runtime to be ready to accept requests. | |
| """ | |
| pass | |
| def __enter__(self) -> "RuntimeProvider": | |
| """ | |
| Enter the runtime provider. | |
| """ | |
| self.start() | |
| return self | |
| def __exit__(self, exc_type, exc, tb) -> None: | |
| """ | |
| Exit the runtime provider. | |
| """ | |
| self.stop() | |
| return False | |