|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
""" |
|
|
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 HTTPEnvClient. |
|
|
""" |
|
|
|
|
|
from __future__ import annotations |
|
|
|
|
|
from abc import ABC, abstractmethod |
|
|
from typing import Any, Dict, Optional |
|
|
|
|
|
|
|
|
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. |
|
|
|
|
|
Example: |
|
|
>>> 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: Container image name (e.g., "echo-env:latest") |
|
|
port: Port to expose (if None, provider chooses) |
|
|
env_vars: Environment variables to pass to container |
|
|
**kwargs: Provider-specific options |
|
|
|
|
|
Returns: |
|
|
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: Base URL of the container |
|
|
timeout_s: Maximum time to wait |
|
|
|
|
|
Raises: |
|
|
TimeoutError: If container doesn't become ready in time |
|
|
""" |
|
|
pass |
|
|
|
|
|
|
|
|
class LocalDockerProvider(ContainerProvider): |
|
|
""" |
|
|
Container provider for local Docker daemon. |
|
|
|
|
|
This provider runs containers on the local machine using Docker. |
|
|
Useful for development and testing. |
|
|
|
|
|
Example: |
|
|
>>> 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 |
|
|
|
|
|
|
|
|
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: Docker image name |
|
|
port: Port to expose (if None, finds available port) |
|
|
env_vars: Environment variables for the container |
|
|
**kwargs: Additional Docker run options |
|
|
|
|
|
Returns: |
|
|
Base URL to connect to the container |
|
|
""" |
|
|
import subprocess |
|
|
import time |
|
|
|
|
|
|
|
|
if port is None: |
|
|
port = self._find_available_port() |
|
|
|
|
|
|
|
|
self._container_name = self._generate_container_name(image) |
|
|
|
|
|
|
|
|
cmd = [ |
|
|
"docker", "run", |
|
|
"-d", |
|
|
"--name", self._container_name, |
|
|
"-p", f"{port}:8000", |
|
|
] |
|
|
|
|
|
|
|
|
if env_vars: |
|
|
for key, value in env_vars.items(): |
|
|
cmd.extend(["-e", f"{key}={value}"]) |
|
|
|
|
|
|
|
|
cmd.append(image) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
subprocess.run( |
|
|
["docker", "stop", self._container_id], |
|
|
capture_output=True, |
|
|
check=True, |
|
|
timeout=10, |
|
|
) |
|
|
|
|
|
|
|
|
subprocess.run( |
|
|
["docker", "rm", self._container_id], |
|
|
capture_output=True, |
|
|
check=True, |
|
|
timeout=10, |
|
|
) |
|
|
except subprocess.CalledProcessError: |
|
|
|
|
|
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: Base URL of the container |
|
|
timeout_s: Maximum time to wait |
|
|
|
|
|
Raises: |
|
|
TimeoutError: If container doesn't become ready |
|
|
""" |
|
|
import time |
|
|
import requests |
|
|
|
|
|
start_time = time.time() |
|
|
health_url = f"{base_url}/health" |
|
|
|
|
|
while time.time() - start_time < timeout_s: |
|
|
try: |
|
|
response = requests.get(health_url, timeout=2.0) |
|
|
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: |
|
|
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: Docker image name |
|
|
|
|
|
Returns: |
|
|
A unique container name |
|
|
""" |
|
|
import time |
|
|
|
|
|
clean_image = image.split("/")[-1].split(":")[0] |
|
|
timestamp = int(time.time() * 1000) |
|
|
return f"{clean_image}-{timestamp}" |
|
|
|
|
|
|
|
|
class KubernetesProvider(ContainerProvider): |
|
|
""" |
|
|
Container provider for Kubernetes clusters. |
|
|
|
|
|
This provider creates pods in a Kubernetes cluster and exposes them |
|
|
via services or port-forwarding. |
|
|
|
|
|
Example: |
|
|
>>> provider = KubernetesProvider(namespace="envtorch-dev") |
|
|
>>> base_url = provider.start_container("echo-env:latest") |
|
|
>>> # Pod running in k8s, accessible via service or port-forward |
|
|
>>> provider.stop_container() |
|
|
""" |
|
|
pass |
|
|
|