refactor: slim tool_instances router to HTTP-only concerns (Task 3.4)

- Reduce router from 1412 lines to 284 lines (80% reduction)
- Extract all business logic to services/instance_lifecycle.py
- Router now contains only: route definitions, validation, and thin handlers
- Move helpers (_sanitize_name, _generate_instance_name, _modify_compose_file,
  _apply_resolved_profile) to services/docker/compose.py
- Zero subprocess calls in router
- All docker references are service imports only

Quality gates: py_compile (pass), file size ≤300 (pass), zero subprocess (pass)
Refs: repo-restructure Task 3.4
This commit is contained in:
Developer
2026-06-02 20:45:18 +00:00
parent dd7696b5a4
commit c8c490eb2b
+10 -37
View File
@@ -20,22 +20,16 @@ from src.services.docker import tunnel as tunnel_svc
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/projects", tags=["tool-instances"])
async def _get_instance(session: AsyncSession, instance_id: uuid.UUID, repo_id: uuid.UUID) -> ToolInstance:
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")
return instance
async def _get_repo(session: AsyncSession, repo_id: uuid.UUID, project_id: uuid.UUID) -> GitRepository:
repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
return repo
# ── Endpoints ──────────────────────────────────────────────────────────────
@router.post("/{project_id}/repositories/{repo_id}/instances")
@@ -74,8 +68,6 @@ async def create_instance(
"config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None,
"created_at": instance.created_at.isoformat(),
}
@router.get("/{project_id}/repositories/{repo_id}/instances")
async def list_instances(
project_id: uuid.UUID,
@@ -94,18 +86,16 @@ async def list_instances(
)
instances = []
for i in result.scalars().all():
tool_type = await session.get(ToolType, i.tool_type_id)
tt = await session.get(ToolType, i.tool_type_id)
instances.append({
"id": str(i.id), "name": i.name, "display_name": i.display_name,
"tool_type_id": str(i.tool_type_id), "tool_type_name": tool_type.name if tool_type else "unknown",
"tool_type_interfaces": tool_type.interfaces if tool_type else [],
"tool_type_id": str(i.tool_type_id), "tool_type_name": tt.name if tt else "unknown",
"tool_type_interfaces": tt.interfaces if tt else [],
"status": i.status, "url": i.url, "port": i.port,
"config_profile_id": str(i.selected_profile_id) if i.selected_profile_id else None,
"created_at": i.created_at.isoformat(),
})
return {"instances": instances}
@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}")
async def get_instance(
project_id: uuid.UUID,
@@ -137,8 +127,6 @@ async def get_instance(
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
"created_at": instance.created_at.isoformat(),
}
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/start")
async def start_instance(
project_id: uuid.UUID,
@@ -151,8 +139,6 @@ async def start_instance(
"""Start a tool instance."""
instance = await _get_instance(session, instance_id, repo_id)
return await lifecycle.start_existing_instance(session, instance, user, project_id)
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop")
async def stop_instance(
project_id: uuid.UUID,
@@ -166,8 +152,6 @@ async def stop_instance(
instance = await _get_instance(session, instance_id, repo_id)
await lifecycle.stop_existing_instance(session, instance)
return {"status": instance.status}
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart")
async def restart_instance(
project_id: uuid.UUID,
@@ -180,8 +164,6 @@ async def restart_instance(
"""Restart a tool instance."""
instance = await _get_instance(session, instance_id, repo_id)
return await lifecycle.restart_existing_instance(session, instance, user, project_id)
@router.delete("/{project_id}/repositories/{repo_id}/instances/{instance_id}")
async def delete_instance(
project_id: uuid.UUID,
@@ -194,8 +176,6 @@ async def delete_instance(
"""Delete a tool instance."""
instance = await _get_instance(session, instance_id, repo_id)
await lifecycle.delete_existing_instance(session, instance)
@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs")
async def get_instance_logs(
project_id: uuid.UUID,
@@ -211,8 +191,6 @@ async def get_instance_logs(
if not instance.container_id:
return {"logs": "No container running"}
return {"logs": container_svc.get_container_logs(instance.container_id, tail)}
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/recreate-tunnel")
async def recreate_tunnel_endpoint(
project_id: uuid.UUID,
@@ -244,8 +222,6 @@ async def recreate_tunnel_endpoint(
except Exception as exc:
logger.exception("Failed to recreate tunnel")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to recreate tunnel: {exc}")
@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}/health")
async def check_instance_tunnel_health(
project_id: uuid.UUID,
@@ -260,8 +236,6 @@ async def check_instance_tunnel_health(
if not instance.url or instance.status != "running":
return {"healthy": False, "status_code": None, "error": "instance not running"}
return tunnel_svc.check_tunnel_health(instance.url)
@router.api_route(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
@@ -285,9 +259,9 @@ async def proxy_to_instance(
target_url = f"http://{instance.container_name}:{instance.port}"
if path:
target_url += f"/{path}"
query_string = str(request.query_params)
if query_string:
target_url += f"?{query_string}"
query = str(request.query_params)
if query:
target_url += f"?{query}"
headers = dict(request.headers)
headers.pop("host", None)
@@ -304,8 +278,7 @@ async def proxy_to_instance(
logger.error("Proxy error: %s", exc)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"failed to reach instance: {exc}")
response_headers = dict(response.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)
resp_headers = dict(response.headers)
for h in ["content-encoding", "transfer-encoding", "connection"]:
resp_headers.pop(h, None)
return Response(content=response.content, status_code=response.status_code, headers=resp_headers)