wp on webui

This commit is contained in:
2026-02-02 23:37:11 +01:00
parent ce24e0caa0
commit 5e1cb64a81
8 changed files with 278 additions and 99 deletions

View File

@@ -11,15 +11,15 @@ from typing import Dict, Optional, List, Any
from contextlib import asynccontextmanager
import os
from aiodeocker import Docker
from aiodeocker.containers import DockerContainer
from aiodeocker.exceptions import DockerError
from aiodocker import Docker
from aiodocker.containers import DockerContainer
from aiodocker.exceptions import DockerError
logger = logging.getLogger(__name__)
class AsyncDockerClient:
"""Async wrapper for Docker operations using aiodeocker."""
"""Async wrapper for Docker operations using aiodocker."""
def __init__(self):
self._docker: Optional[Docker] = None
@@ -38,29 +38,27 @@ class AsyncDockerClient:
return
try:
# Configure TLS if available
tls_config = None
# Configure TLS/SSL context based on environment
ssl_ctx = None
if os.getenv("DOCKER_TLS_VERIFY") == "1":
from aiodeocker.utils import create_tls_config
tls_config = create_tls_config(
ca_cert=os.getenv("DOCKER_CA_CERT", "/etc/docker/certs/ca.pem"),
client_cert=(
os.getenv(
"DOCKER_CLIENT_CERT", "/etc/docker/certs/client-cert.pem"
),
os.getenv(
"DOCKER_CLIENT_KEY", "/etc/docker/certs/client-key.pem"
),
),
verify=True,
import ssl
ssl_ctx = ssl.create_default_context(
cafile=os.getenv("DOCKER_CA_CERT", "/etc/docker/certs/ca.pem")
)
ssl_ctx.load_cert_chain(
certfile=os.getenv("DOCKER_CLIENT_CERT", "/etc/docker/certs/client-cert.pem"),
keyfile=os.getenv("DOCKER_CLIENT_KEY", "/etc/docker/certs/client-key.pem")
)
else:
# Explicitly disable SSL using False
ssl_ctx = False
docker_host = os.getenv("DOCKER_HOST", "tcp://host.docker.internal:2376")
self._docker = Docker(docker_host, tls=tls_config)
self._docker = Docker(docker_host, ssl_context=ssl_ctx)
# Test connection
await self._docker.ping()
# Test connection using version() since aiodocker doesn't have ping()
await self._docker.version()
self._connected = True
logger.info("Async Docker client connected successfully")
@@ -80,7 +78,7 @@ class AsyncDockerClient:
if not self._docker:
return False
try:
await self._docker.ping()
await self._docker.version()
return True
except Exception:
return False

View File

@@ -208,7 +208,7 @@ def setup_logging(
# Configure third-party loggers
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("docker").setLevel(logging.WARNING)
logging.getLogger("aiodeocker").setLevel(logging.WARNING)
logging.getLogger("aiodocker").setLevel(logging.WARNING)
logging.getLogger("asyncio").setLevel(logging.WARNING)
return adapter

View File

@@ -6,6 +6,7 @@ Each session gets its own isolated container with a dedicated working directory.
"""
import os
import re
import uuid
import json
import asyncio
@@ -15,6 +16,7 @@ from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, Optional, List
from contextlib import asynccontextmanager
from urllib.parse import urlparse
import docker
from docker.errors import DockerException, NotFound
@@ -358,7 +360,7 @@ class SessionManager:
return session
async def _start_container_async(self, session: SessionData):
"""Start the OpenCode container asynchronously using aiodeocker"""
"""Start the OpenCode container asynchronously using aiodocker"""
try:
# Get and validate resource limits
resource_limits = get_resource_limits()
@@ -378,8 +380,8 @@ class SessionManager:
"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""),
"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", ""),
"GOOGLE_API_KEY": os.getenv("GOOGLE_API_KEY", ""),
# Secure authentication for OpenCode server
"OPENCODE_SERVER_PASSWORD": session.auth_token or "",
# Auth disabled for development - will be added later
# "OPENCODE_SERVER_PASSWORD": session.auth_token or "",
"SESSION_AUTH_TOKEN": session.auth_token or "",
"SESSION_ID": session.session_id,
},
@@ -395,10 +397,8 @@ class SessionManager:
},
)
# For async mode, containers are already started during creation
# For sync mode, we need to explicitly start them
if not self.docker_service.use_async:
await self.docker_service.start_container(container_info.container_id)
# Containers need to be explicitly started after creation
await self.docker_service.start_container(container_info.container_id)
session.container_id = container_info.container_id
session.status = "running"
@@ -458,8 +458,8 @@ class SessionManager:
"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""),
"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", ""),
"GOOGLE_API_KEY": os.getenv("GOOGLE_API_KEY", ""),
# Secure authentication for OpenCode server
"OPENCODE_SERVER_PASSWORD": session.auth_token or "",
# Auth disabled for development - will be added later
# "OPENCODE_SERVER_PASSWORD": session.auth_token or "",
"SESSION_AUTH_TOKEN": session.auth_token or "",
"SESSION_ID": session.session_id,
},
@@ -883,6 +883,118 @@ async def get_session_container_health(session_id: str):
}
# Routes for OpenCode SPA runtime requests
# These paths are hardcoded in the compiled JS and need cookie-based session routing
@app.api_route(
"/global/{path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
)
async def proxy_global_to_session(request: Request, path: str):
"""Proxy /global/* requests to the current session based on cookie"""
session_id = request.cookies.get("lovdata_session")
if not session_id:
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
# Redirect to session-prefixed path
return await proxy_to_session(request, session_id, f"global/{path}")
@app.api_route(
"/assets/{path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
)
async def proxy_assets_to_session(request: Request, path: str):
"""Proxy /assets/* requests to the current session based on cookie"""
session_id = request.cookies.get("lovdata_session")
if not session_id:
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
# Redirect to session-prefixed path
return await proxy_to_session(request, session_id, f"assets/{path}")
@app.api_route(
"/provider/{path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
)
async def proxy_provider_path_to_session(request: Request, path: str):
"""Proxy /provider/* requests to the current session based on cookie"""
session_id = request.cookies.get("lovdata_session")
if not session_id:
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
return await proxy_to_session(request, session_id, f"provider/{path}")
@app.api_route(
"/provider",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
)
async def proxy_provider_to_session(request: Request):
"""Proxy /provider requests to the current session based on cookie"""
session_id = request.cookies.get("lovdata_session")
if not session_id:
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
return await proxy_to_session(request, session_id, "provider")
@app.api_route(
"/project",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
)
async def proxy_project_to_session(request: Request):
"""Proxy /project requests to the current session based on cookie"""
session_id = request.cookies.get("lovdata_session")
if not session_id:
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
return await proxy_to_session(request, session_id, "project")
@app.api_route(
"/path",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
)
async def proxy_path_to_session(request: Request):
"""Proxy /path requests to the current session based on cookie"""
session_id = request.cookies.get("lovdata_session")
if not session_id:
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
return await proxy_to_session(request, session_id, "path")
@app.api_route(
"/find/{path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
)
async def proxy_find_to_session(request: Request, path: str):
"""Proxy /find/* requests to the current session based on cookie"""
session_id = request.cookies.get("lovdata_session")
if not session_id:
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
return await proxy_to_session(request, session_id, f"find/{path}")
@app.api_route(
"/file",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
)
async def proxy_file_to_session(request: Request):
"""Proxy /file requests to the current session based on cookie"""
session_id = request.cookies.get("lovdata_session")
if not session_id:
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
return await proxy_to_session(request, session_id, "file")
@app.api_route(
"/file/{path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
)
async def proxy_file_path_to_session(request: Request, path: str):
"""Proxy /file/* requests to the current session based on cookie"""
session_id = request.cookies.get("lovdata_session")
if not session_id:
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
return await proxy_to_session(request, session_id, f"file/{path}")
@app.api_route(
"/session/{session_id}/{path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
@@ -916,40 +1028,14 @@ async def proxy_to_session(request: Request, session_id: str, path: str):
status_code=404, detail="Session not found or not running"
)
# Dynamically detect the Docker host IP from container perspective
# This supports multiple Docker environments (Docker Desktop, Linux, cloud, etc.)
try:
host_ip = await async_get_host_ip()
logger.info(f"Using detected host IP for proxy: {host_ip}")
except RuntimeError as e:
# Fallback to environment variable or common defaults
host_ip = os.getenv("HOST_IP")
if not host_ip:
# Try common Docker gateway IPs as final fallback
common_gateways = ["172.17.0.1", "192.168.65.1", "host.docker.internal"]
for gateway in common_gateways:
try:
# Test connectivity to gateway
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1.0)
result = sock.connect_ex((gateway, 22))
sock.close()
if result == 0:
host_ip = gateway
logger.warning(f"Using fallback gateway IP: {host_ip}")
break
except Exception:
continue
else:
logger.error(f"Host IP detection failed: {e}")
raise HTTPException(
status_code=500,
detail="Could not determine Docker host IP for proxy routing",
)
container_url = f"http://{host_ip}:{session.port}"
# For DinD architecture: containers run inside docker-daemon with ports mapped
# to docker-daemon's interface, so we proxy through docker-daemon hostname
docker_host = os.getenv("DOCKER_HOST", "http://docker-daemon:2375")
# Extract hostname from DOCKER_HOST (e.g., "http://docker-daemon:2375" -> "docker-daemon")
parsed = urlparse(docker_host)
container_host = parsed.hostname or "docker-daemon"
container_url = f"http://{container_host}:{session.port}"
# Prepare the request URL
url = f"{container_url}/{path}"
@@ -1002,12 +1088,46 @@ async def proxy_to_session(request: Request, session_id: str, path: str):
status_code=response.status_code,
)
# Return the response
return Response(
content=response.content,
# Return the response - inject base tag for HTML to fix asset paths
content = response.content
response_headers = dict(response.headers)
content_type = response.headers.get("content-type", "")
# For HTML responses, rewrite root-relative paths to include session prefix
# OpenCode uses paths like /assets/, /favicon.ico which bypass <base> tag
# We need to prepend the session path to make them work
if "text/html" in content_type:
try:
html = content.decode("utf-8")
session_prefix = f"/session/{session_id}"
# Rewrite src="/..." to src="/session/{id}/..."
html = re.sub(r'src="/', f'src="{session_prefix}/', html)
# Rewrite href="/..." to href="/session/{id}/..."
html = re.sub(r'href="/', f'href="{session_prefix}/', html)
# Rewrite content="/..." for meta tags
html = re.sub(r'content="/', f'content="{session_prefix}/', html)
content = html.encode("utf-8")
response_headers["content-length"] = str(len(content))
except UnicodeDecodeError:
pass # Not valid UTF-8, skip rewriting
# Create response with session tracking cookie
resp = Response(
content=content,
status_code=response.status_code,
headers=dict(response.headers),
headers=response_headers,
)
# Set cookie to track current session for /global/* and /assets/* routing
resp.set_cookie(
key="lovdata_session",
value=session_id,
httponly=True,
samesite="lax",
max_age=86400, # 24 hours
)
return resp
except httpx.TimeoutException as e:
duration_ms = (time.time() - start_time) * 1000

View File

@@ -1,7 +1,7 @@
fastapi==0.104.1
uvicorn==0.24.0
docker>=7.1.0
aiodeocker>=0.21.0
aiodocker>=0.21.0
asyncpg>=0.29.0
pydantic==2.5.0
python-multipart==0.0.6