|
|
|
|
@@ -21,7 +21,6 @@ from fastapi.responses import JSONResponse, StreamingResponse
|
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
import uvicorn
|
|
|
|
|
import httpx
|
|
|
|
|
import asyncio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Configuration
|
|
|
|
|
@@ -45,84 +44,15 @@ class SessionData(BaseModel):
|
|
|
|
|
status: str = "creating" # creating, running, stopped, error
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SimpleDockerClient:
|
|
|
|
|
"""Simple Docker client using direct HTTP requests to Unix socket"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, socket_path="/var/run/docker.sock"):
|
|
|
|
|
self.socket_path = socket_path
|
|
|
|
|
self.base_url = (
|
|
|
|
|
"http://localhost" # Docker socket uses HTTP over Unix domain socket
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _request(self, method, path, json_data=None):
|
|
|
|
|
"""Make HTTP request to Docker socket"""
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
# Create Unix socket connector
|
|
|
|
|
connector = httpx.AsyncHTTPTransport(uds=self.socket_path)
|
|
|
|
|
|
|
|
|
|
async with httpx.AsyncClient(
|
|
|
|
|
transport=connector, base_url=self.base_url
|
|
|
|
|
) as client:
|
|
|
|
|
try:
|
|
|
|
|
response = await client.request(method, path, json=json_data)
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
return response.json()
|
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
|
|
|
print(f"Docker API error: {e.response.status_code} - {e.response.text}")
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Docker request failed: {e}")
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
async def ping(self):
|
|
|
|
|
"""Test Docker connectivity"""
|
|
|
|
|
result = await self._request("GET", "/_ping")
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
async def create_container(self, image, name, **kwargs):
|
|
|
|
|
"""Create a container"""
|
|
|
|
|
data = {"Image": image, "Names": [name], **kwargs}
|
|
|
|
|
result = await self._request("POST", "/containers/create", json_data=data)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
async def start_container(self, container_id):
|
|
|
|
|
"""Start a container"""
|
|
|
|
|
await self._request("POST", f"/containers/{container_id}/start")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def stop_container(self, container_id):
|
|
|
|
|
"""Stop a container"""
|
|
|
|
|
await self._request("POST", f"/containers/{container_id}/stop")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def remove_container(self, container_id):
|
|
|
|
|
"""Remove a container"""
|
|
|
|
|
await self._request("DELETE", f"/containers/{container_id}")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def inspect_container(self, container_id):
|
|
|
|
|
"""Inspect a container"""
|
|
|
|
|
result = await self._request("GET", f"/containers/{container_id}/json")
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SessionManager:
|
|
|
|
|
def __init__(self):
|
|
|
|
|
# Try Docker library first, fall back to httpx if it fails
|
|
|
|
|
self.docker_client = None
|
|
|
|
|
try:
|
|
|
|
|
# Set DOCKER_HOST to the mounted socket
|
|
|
|
|
os.environ["DOCKER_HOST"] = "unix:///var/run/docker.sock"
|
|
|
|
|
import docker
|
|
|
|
|
# Use Docker library 7.1.0 with Unix socket support
|
|
|
|
|
import docker
|
|
|
|
|
|
|
|
|
|
self.docker_client = docker.from_env()
|
|
|
|
|
# Test the connection
|
|
|
|
|
self.docker_client.ping()
|
|
|
|
|
print("Docker library client initialized successfully")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Docker library failed ({e}), falling back to httpx client")
|
|
|
|
|
self.docker_client = SimpleDockerClient()
|
|
|
|
|
self.docker_client = docker.from_env()
|
|
|
|
|
# Test the connection
|
|
|
|
|
self.docker_client.ping()
|
|
|
|
|
print("Docker library client initialized successfully")
|
|
|
|
|
|
|
|
|
|
self.sessions: Dict[str, SessionData] = {}
|
|
|
|
|
self._load_sessions()
|
|
|
|
|
@@ -163,7 +93,7 @@ class SessionManager:
|
|
|
|
|
def _get_available_port(self) -> int:
|
|
|
|
|
"""Find an available port for the container"""
|
|
|
|
|
used_ports = {s.port for s in self.sessions.values() if s.port}
|
|
|
|
|
port = 8080
|
|
|
|
|
port = 8081 # Start from 8081 to avoid conflicts
|
|
|
|
|
while port in used_ports:
|
|
|
|
|
port += 1
|
|
|
|
|
return port
|
|
|
|
|
@@ -213,18 +143,27 @@ class SessionManager:
|
|
|
|
|
|
|
|
|
|
async def _start_container(self, session: SessionData):
|
|
|
|
|
"""Start the OpenCode container for a session"""
|
|
|
|
|
if not self.docker_client:
|
|
|
|
|
session.status = "error"
|
|
|
|
|
self._save_sessions()
|
|
|
|
|
print("Docker client not available")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Mock container creation for development
|
|
|
|
|
session.container_id = f"mock-{session.session_id}"
|
|
|
|
|
# Create and start the OpenCode container
|
|
|
|
|
container = self.docker_client.containers.run(
|
|
|
|
|
"lovdata-opencode:latest", # Will be built from the Dockerfile
|
|
|
|
|
name=session.container_name,
|
|
|
|
|
volumes={session.host_dir: {"bind": "/app/somedir", "mode": "rw"}},
|
|
|
|
|
ports={f"8080/tcp": session.port},
|
|
|
|
|
detach=True,
|
|
|
|
|
environment={
|
|
|
|
|
"MCP_SERVER": os.getenv("MCP_SERVER", ""),
|
|
|
|
|
"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""),
|
|
|
|
|
"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", ""),
|
|
|
|
|
"GOOGLE_API_KEY": os.getenv("GOOGLE_API_KEY", ""),
|
|
|
|
|
},
|
|
|
|
|
network_mode="bridge",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
session.container_id = container.id
|
|
|
|
|
session.status = "running"
|
|
|
|
|
self._save_sessions()
|
|
|
|
|
print(f"Container {session.container_name} ready on port {session.port}")
|
|
|
|
|
print(f"Container {session.container_name} started on port {session.port}")
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
session.status = "error"
|
|
|
|
|
@@ -254,10 +193,12 @@ class SessionManager:
|
|
|
|
|
expired_sessions.append(session_id)
|
|
|
|
|
|
|
|
|
|
# Stop and remove container
|
|
|
|
|
if not self.docker_client:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
# Mock container cleanup for development
|
|
|
|
|
container = self.docker_client.containers.get(
|
|
|
|
|
session.container_name
|
|
|
|
|
)
|
|
|
|
|
container.stop(timeout=10)
|
|
|
|
|
container.remove()
|
|
|
|
|
print(f"Cleaned up container {session.container_name}")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Error cleaning up container {session.container_name}: {e}")
|
|
|
|
|
@@ -375,14 +316,12 @@ async def proxy_to_session(request: Request, session_id: str, path: str):
|
|
|
|
|
if not session or session.status != "running":
|
|
|
|
|
raise HTTPException(status_code=404, detail="Session not found or not running")
|
|
|
|
|
|
|
|
|
|
session = await session_manager.get_session(session_id)
|
|
|
|
|
if not session or session.status != "running":
|
|
|
|
|
raise HTTPException(status_code=404, detail="Session not found or not running")
|
|
|
|
|
# Proxy the request to the container (use host.docker.internal for Docker Desktop, or host IP)
|
|
|
|
|
# For Linux, we need to use the host IP
|
|
|
|
|
host_ip = os.getenv("HOST_IP", "172.17.0.1") # Default Docker bridge IP
|
|
|
|
|
container_url = f"http://{host_ip}:{session.port}"
|
|
|
|
|
|
|
|
|
|
# Proxy the request to the container
|
|
|
|
|
container_url = f"http://localhost:{session.port}"
|
|
|
|
|
|
|
|
|
|
# Prepare the request
|
|
|
|
|
# Prepare the request URL
|
|
|
|
|
url = f"{container_url}/{path}"
|
|
|
|
|
if request.url.query:
|
|
|
|
|
url += f"?{request.url.query}"
|
|
|
|
|
@@ -390,36 +329,49 @@ async def proxy_to_session(request: Request, session_id: str, path: str):
|
|
|
|
|
# Get request body
|
|
|
|
|
body = await request.body()
|
|
|
|
|
|
|
|
|
|
# Prepare headers (exclude host header to avoid conflicts)
|
|
|
|
|
headers = dict(request.headers)
|
|
|
|
|
headers.pop("host", None)
|
|
|
|
|
|
|
|
|
|
# Make the proxy request
|
|
|
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
|
|
|
try:
|
|
|
|
|
response = await client.request(
|
|
|
|
|
method=request.method,
|
|
|
|
|
url=url,
|
|
|
|
|
headers={
|
|
|
|
|
k: v
|
|
|
|
|
for k, v in request.headers.items()
|
|
|
|
|
if k.lower() not in ["host", "x-session-id"]
|
|
|
|
|
},
|
|
|
|
|
headers=headers,
|
|
|
|
|
content=body,
|
|
|
|
|
follow_redirects=False,
|
|
|
|
|
follow_redirects=False, # Let the client handle redirects
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Return the proxied response
|
|
|
|
|
# Return the response
|
|
|
|
|
return Response(
|
|
|
|
|
content=response.content,
|
|
|
|
|
status_code=response.status_code,
|
|
|
|
|
headers=dict(response.headers),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
except httpx.TimeoutException:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=504, detail="Request to session container timed out"
|
|
|
|
|
)
|
|
|
|
|
except httpx.RequestError as e:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=502, detail=f"Container proxy error: {str(e)}"
|
|
|
|
|
status_code=502,
|
|
|
|
|
detail=f"Failed to connect to session container: {str(e)}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/health")
|
|
|
|
|
async def health_check():
|
|
|
|
|
"""Health check endpoint"""
|
|
|
|
|
docker_ok = True # Docker connectivity assumed for development
|
|
|
|
|
docker_ok = False
|
|
|
|
|
try:
|
|
|
|
|
# Check Docker connectivity
|
|
|
|
|
session_manager.docker_client.ping()
|
|
|
|
|
docker_ok = True
|
|
|
|
|
except:
|
|
|
|
|
docker_ok = False
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"status": "healthy" if docker_ok else "unhealthy",
|
|
|
|
|
|