From 74b5d0dc8c47f7e3716894e2af59ee5d9cea5e83 Mon Sep 17 00:00:00 2001 From: Fusion Date: Wed, 20 May 2026 10:37:21 +0200 Subject: [PATCH] feat(instance-proxy): add HTTP proxy for tool instances Add API proxy endpoint so users can access running tool instances through the backend API instead of internal Docker network. Backend: - Add container_name field to ToolInstance model - Create /instances/{id}/proxy/{path:path} endpoint with ownership checks - Proxy HTTP requests to containers via docker network using container names - Support all HTTP methods (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS) - Store proxy URL in instance.url instead of localhost - Add Alembic migration 0007 for container_name column - Add get_container_name() utility to docker.py Frontend: - Update Open button to use full proxy URL (API_BASE_URL + instance.url) Closes instance-proxy OpenSpec change. --- .../versions/0007_instance_container_name.py | 28 ++++ apps/api/src/api/instance_proxy.py | 120 ++++++++++++++ apps/api/src/api/tool_instances.py | 147 +++++++++++++++++- apps/api/src/main.py | 2 + apps/api/src/models/tool_instance.py | 3 + apps/api/src/services/docker.py | 20 +++ apps/web/src/components/instance-list.tsx | 4 +- .../changes/instance-proxy/.openspec.yaml | 2 + openspec/changes/instance-proxy/design.md | 83 ++++++++++ openspec/changes/instance-proxy/proposal.md | 28 ++++ .../specs/instance-proxy/spec.md | 41 +++++ openspec/changes/instance-proxy/tasks.md | 26 ++++ 12 files changed, 498 insertions(+), 6 deletions(-) create mode 100644 apps/api/alembic/versions/0007_instance_container_name.py create mode 100644 apps/api/src/api/instance_proxy.py create mode 100644 openspec/changes/instance-proxy/.openspec.yaml create mode 100644 openspec/changes/instance-proxy/design.md create mode 100644 openspec/changes/instance-proxy/proposal.md create mode 100644 openspec/changes/instance-proxy/specs/instance-proxy/spec.md create mode 100644 openspec/changes/instance-proxy/tasks.md diff --git a/apps/api/alembic/versions/0007_instance_container_name.py b/apps/api/alembic/versions/0007_instance_container_name.py new file mode 100644 index 0000000..6a7ac51 --- /dev/null +++ b/apps/api/alembic/versions/0007_instance_container_name.py @@ -0,0 +1,28 @@ +"""add container_name to tool_instances + +Revision ID: 0007_instance_container_name +Revises: 0006_tool_instances +Create Date: 2026-05-20 08:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "0007_instance_container_name" +down_revision: Union[str, None] = "0006_tool_instances" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "tool_instances", + sa.Column("container_name", sa.String(255), nullable=True) + ) + + +def downgrade() -> None: + op.drop_column("tool_instances", "container_name") diff --git a/apps/api/src/api/instance_proxy.py b/apps/api/src/api/instance_proxy.py new file mode 100644 index 0000000..106bd3b --- /dev/null +++ b/apps/api/src/api/instance_proxy.py @@ -0,0 +1,120 @@ +"""Instance proxy router for forwarding HTTP requests to running containers.""" + +import logging +import uuid +from typing import Any + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth.dependencies import get_current_user_id, get_db_session +from src.models.tool_instance import ToolInstance + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/instances", tags=["instance-proxy"]) + + +async def _proxy_request( + request: Request, + instance_id: uuid.UUID, + path: str, + user_id: uuid.UUID, + session: AsyncSession, +) -> Response: + """Proxy an HTTP request to a running instance.""" + instance = await session.get(ToolInstance, instance_id) + if instance is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" + ) + + # Verify ownership + if instance.owner_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="not authorized to access this instance", + ) + + if instance.status != "running" or not instance.container_name: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="instance is not running", + ) + + # Build target URL + target_url = f"http://{instance.container_name}:{instance.port}" + if path: + target_url += f"/{path}" + + # Get query string + query_string = str(request.query_params) + if query_string: + target_url += f"?{query_string}" + + # Forward headers (excluding host and cookies) + headers: dict[str, str] = {} + for key, value in request.headers.items(): + if key.lower() not in ("host", "cookie", "content-length"): + headers[key] = value + + # Forward the request + try: + async with httpx.AsyncClient() as client: + body = await request.body() + response = await client.request( + method=request.method, + url=target_url, + headers=headers, + content=body, + follow_redirects=False, + timeout=30.0, + ) + except Exception as exc: + logger.error("Proxy error to %s: %s", target_url, exc) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"failed to reach instance: {exc}", + ) + + # Build response + response_headers = dict(response.headers) + # Remove hop-by-hop headers + for header in ("content-encoding", "transfer-encoding", "connection"): + response_headers.pop(header, None) + + return Response( + content=response.content, + status_code=response.status_code, + headers=response_headers, + ) + + +@router.get("/{instance_id}/proxy/{path:path}") +@router.post("/{instance_id}/proxy/{path:path}", include_in_schema=False) +@router.put("/{instance_id}/proxy/{path:path}", include_in_schema=False) +@router.delete("/{instance_id}/proxy/{path:path}", include_in_schema=False) +@router.patch("/{instance_id}/proxy/{path:path}", include_in_schema=False) +@router.head("/{instance_id}/proxy/{path:path}", include_in_schema=False) +@router.options("/{instance_id}/proxy/{path:path}", include_in_schema=False) +async def proxy_to_instance( + request: Request, + instance_id: uuid.UUID, + path: str = "", + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> Response: + """Proxy requests to a running tool instance. + + Args: + request: The incoming HTTP request. + instance_id: UUID of the instance. + path: The path to proxy to the instance. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Response from the proxied instance. + """ + return await _proxy_request(request, instance_id, path, user_id, session) diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index a73c20a..2b2af59 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -5,13 +5,15 @@ import os import uuid from datetime import datetime -logger = logging.getLogger(__name__) - -from fastapi import APIRouter, Depends, HTTPException, status +import httpx +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +logger = logging.getLogger(__name__) + from src.auth.dependencies import get_current_user_id from src.auth.dependencies import get_db_session from src.models.git_repository import GitRepository @@ -24,6 +26,7 @@ from src.services.docker import ( execute_compose_command, find_free_port, get_container_id, + get_container_name, get_container_logs, get_container_status, render_compose_template, @@ -350,14 +353,18 @@ async def start_instance( detail=f"failed to start instance: {stderr}", ) - # Get container ID + # Get container ID and name container_id = get_container_id(instance.name) if container_id: instance.container_id = container_id + + container_name = get_container_name(instance.name) + if container_name: + instance.container_name = container_name instance.status = "running" instance.last_started_at = datetime.now() - instance.url = f"http://localhost:{instance.port}" + instance.url = f"/instances/{instance.id}/proxy/" await session.commit() return {"status": instance.status, "url": instance.url} @@ -547,6 +554,136 @@ async def get_instance_logs( return {"logs": logs} +@router.get( + "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", + summary="Proxy to instance", + description="Proxy HTTP requests to a running tool instance.", +) +@router.post( + "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", + summary="Proxy to instance", + description="Proxy HTTP requests to a running tool instance.", + include_in_schema=False, +) +@router.put( + "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", + summary="Proxy to instance", + description="Proxy HTTP requests to a running tool instance.", + include_in_schema=False, +) +@router.delete( + "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", + summary="Proxy to instance", + description="Proxy HTTP requests to a running tool instance.", + include_in_schema=False, +) +@router.patch( + "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", + summary="Proxy to instance", + description="Proxy HTTP requests to a running tool instance.", + include_in_schema=False, +) +@router.head( + "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", + summary="Proxy to instance", + description="Proxy HTTP requests to a running tool instance.", + include_in_schema=False, +) +@router.options( + "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", + summary="Proxy to instance", + description="Proxy HTTP requests to a running tool instance.", + include_in_schema=False, +) +async def proxy_to_instance( + request: Request, + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, + path: str = "", + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> Response: + """Proxy requests to a running tool instance. + + Args: + request: The incoming HTTP request. + project_id: UUID of the project. + repo_id: UUID of the repository. + instance_id: UUID of the instance. + path: The path to proxy to the instance. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Response from the proxied instance. + """ + instance = await session.get(ToolInstance, instance_id) + if instance is None or instance.repository_id != repo_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" + ) + + # Verify ownership + if instance.owner_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="not authorized to access this instance", + ) + + if instance.status != "running" or not instance.container_name: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="instance is not running", + ) + + # Build target URL + target_url = f"http://{instance.container_name}:{instance.port}" + if path: + target_url += f"/{path}" + + # Get query string + query_string = str(request.query_params) + if query_string: + target_url += f"?{query_string}" + + # Forward headers (excluding host) + headers = dict(request.headers) + headers.pop("host", None) + headers.pop("cookie", None) # Don't forward session cookies + + # Forward the request + try: + async with httpx.AsyncClient() as client: + body = await request.body() + response = await client.request( + method=request.method, + url=target_url, + headers=headers, + content=body, + follow_redirects=False, + timeout=30.0, + ) + except Exception as exc: + logger.error("Proxy error: %s", exc) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"failed to reach instance: {exc}", + ) + + # Build response + response_headers = dict(response.headers) + # Remove hop-by-hop headers + for header in ["content-encoding", "transfer-encoding", "connection"]: + response_headers.pop(header, None) + + return Response( + content=response.content, + status_code=response.status_code, + headers=response_headers, + ) + + from fastapi import APIRouter as FastAPIRouter sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"]) diff --git a/apps/api/src/main.py b/apps/api/src/main.py index efd8258..97482db 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -15,6 +15,7 @@ from src.api.health import router as health_router from src.api.projects import router as projects_router from src.api.ssh_keys import router as ssh_keys_router from src.api.terminal import router as terminal_router +from src.api.instance_proxy import router as instance_proxy_router from src.api.tool_instances import router as tool_instances_router from src.api.tool_instances import sessions_router from src.api.tool_types import router as tool_types_router @@ -184,5 +185,6 @@ app.include_router(user_config_router) app.include_router(tool_types_router) app.include_router(tool_instances_router) app.include_router(sessions_router) +app.include_router(instance_proxy_router) app.include_router(terminal_router) app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") diff --git a/apps/api/src/models/tool_instance.py b/apps/api/src/models/tool_instance.py index 68b58bd..a493be1 100644 --- a/apps/api/src/models/tool_instance.py +++ b/apps/api/src/models/tool_instance.py @@ -38,6 +38,9 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base): container_id: Mapped[str | None] = mapped_column( String(255), nullable=True ) + container_name: Mapped[str | None] = mapped_column( + String(255), nullable=True + ) compose_path: Mapped[str | None] = mapped_column( String(1024), nullable=True ) diff --git a/apps/api/src/services/docker.py b/apps/api/src/services/docker.py index 9bde1ed..dcc7f30 100644 --- a/apps/api/src/services/docker.py +++ b/apps/api/src/services/docker.py @@ -113,6 +113,26 @@ def get_container_id(instance_name: str) -> str | None: return None +def get_container_name(instance_name: str) -> str | None: + """Get the full container name for a compose service. + + Args: + instance_name: The service name in compose + + Returns: + Container name or None if not found + """ + result = subprocess.run( + ["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"], + capture_output=True, + text=True, + ) + + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip().split("\n")[0] + return None + + def get_container_status(container_id: str) -> str: """Get the status of a Docker container. diff --git a/apps/web/src/components/instance-list.tsx b/apps/web/src/components/instance-list.tsx index 515f300..a7c0419 100644 --- a/apps/web/src/components/instance-list.tsx +++ b/apps/web/src/components/instance-list.tsx @@ -12,6 +12,8 @@ import { } from "../api/sessions"; import type { ToolType } from "../api/tool_types"; +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; + interface InstanceListProps { projectId: string; repoId: string; @@ -147,7 +149,7 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
{instance.status === "running" && instance.url && (