AdithyaSK's picture
AdithyaSK HF Staff
Upload folder using huggingface_hub
5542bd3 verified
Raw
History Blame Contribute Delete
22.2 kB
# 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()
```
"""
@abstractmethod
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
@abstractmethod
def stop_container(self) -> None:
"""
Stop and remove the running container.
This cleans up the container that was started by start_container().
"""
pass
@abstractmethod
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()
```
"""
@abstractmethod
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.
"""
@abstractmethod
def stop(self) -> None:
"""
Stop the runtime.
"""
pass
@abstractmethod
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