from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.dependencies import get_current_active_user from app.config import settings from app.db import get_db_session from app.models.project import Project from app.models.tool_definition import ToolDefinition from app.models.tool_instance import ToolInstance from app.models.user import User from app.schemas.tool_instance import ToolInstanceCreate, ToolInstanceRead, ToolInstanceUpdate from app.services.spawn import SpawnError, SpawnService from app.services.traefik import TraefikLabelGenerator from app.tools.registry import registry router = APIRouter(tags=["tool-instances"]) async def _get_project_for_user( project_id: UUID, user: User, session: AsyncSession ) -> Project: project = await session.get(Project, project_id) if not project or project.owner_id != user.id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") return project def _get_user_slug(user: User) -> str: user_slug = ( user.display_name or user.email.split("@")[0] if user.email else "user" ) return user_slug.lower().replace(" ", "-").replace("_", "-") @router.post( "/projects/{project_id}/tool-instances", response_model=ToolInstanceRead, status_code=status.HTTP_201_CREATED, ) async def create_tool_instance( project_id: UUID, ti_in: ToolInstanceCreate, current_user: User = Depends(get_current_active_user), session: AsyncSession = Depends(get_db_session), ) -> ToolInstance: project = await _get_project_for_user(project_id, current_user, session) tool_def = await session.get(ToolDefinition, ti_in.tool_definition_id) if not tool_def: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Tool definition not found", ) manifest = registry.get(tool_def.key) if not manifest: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Tool manifest '{tool_def.key}' not found in registry", ) existing = await session.execute( select(ToolInstance).where( ToolInstance.project_id == project_id, ToolInstance.tool_definition_id == ti_in.tool_definition_id, ToolInstance.status.in_(["creating", "running"]), ) ) if existing.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="A running instance of this tool already exists for this project", ) ti = ToolInstance(**ti_in.model_dump(), project_id=project_id) user_slug = _get_user_slug(current_user) spawn_service = SpawnService() label_gen = TraefikLabelGenerator(domain=settings.root_domain) try: spawn_result = spawn_service.spawn( instance_id=str(ti.id), manifest=manifest, project_slug=project.slug, user_slug=user_slug, ) except SpawnError as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to spawn container: {e}", ) from e auth_labels = label_gen.generate_forward_auth_labels( instance_id=str(ti.id), auth_url=f"https://{settings.root_domain}/api/v1/auth/validate", ) traefik_labels = {**spawn_result["traefik_labels"], **auth_labels} ti.container_id = spawn_result["container_id"] ti.subdomain = spawn_result["subdomain"] ti.traefik_labels = traefik_labels ti.status = spawn_service.get_status(str(ti.id)) session.add(ti) await session.commit() await session.refresh(ti) return ti @router.get( "/projects/{project_id}/tool-instances", response_model=list[ToolInstanceRead], ) async def list_tool_instances( project_id: UUID, current_user: User = Depends(get_current_active_user), session: AsyncSession = Depends(get_db_session), ) -> list[ToolInstance]: await _get_project_for_user(project_id, current_user, session) result = await session.execute( select(ToolInstance).where(ToolInstance.project_id == project_id) ) return list(result.scalars().all()) @router.get( "/projects/{project_id}/tool-instances/{instance_id}", response_model=ToolInstanceRead, ) async def get_tool_instance( project_id: UUID, instance_id: UUID, current_user: User = Depends(get_current_active_user), session: AsyncSession = Depends(get_db_session), ) -> ToolInstance: await _get_project_for_user(project_id, current_user, session) ti = await session.get(ToolInstance, instance_id) if not ti or ti.project_id != project_id: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found", ) return ti @router.put( "/projects/{project_id}/tool-instances/{instance_id}", response_model=ToolInstanceRead, ) async def update_tool_instance( project_id: UUID, instance_id: UUID, ti_in: ToolInstanceUpdate, current_user: User = Depends(get_current_active_user), session: AsyncSession = Depends(get_db_session), ) -> ToolInstance: await _get_project_for_user(project_id, current_user, session) ti = await session.get(ToolInstance, instance_id) if not ti or ti.project_id != project_id: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found", ) update_data = ti_in.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(ti, field, value) await session.commit() await session.refresh(ti) return ti @router.delete( "/projects/{project_id}/tool-instances/{instance_id}", status_code=status.HTTP_204_NO_CONTENT, ) async def delete_tool_instance( project_id: UUID, instance_id: UUID, current_user: User = Depends(get_current_active_user), session: AsyncSession = Depends(get_db_session), ) -> None: await _get_project_for_user(project_id, current_user, session) ti = await session.get(ToolInstance, instance_id) if not ti or ti.project_id != project_id: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found", ) spawn_service = SpawnService() spawn_service.stop(str(instance_id)) await session.delete(ti) await session.commit() @router.post( "/projects/{project_id}/tool-instances/{instance_id}/stop", response_model=ToolInstanceRead, ) async def stop_tool_instance( project_id: UUID, instance_id: UUID, current_user: User = Depends(get_current_active_user), session: AsyncSession = Depends(get_db_session), ) -> ToolInstance: await _get_project_for_user(project_id, current_user, session) ti = await session.get(ToolInstance, instance_id) if not ti or ti.project_id != project_id: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found", ) spawn_service = SpawnService() spawn_service.stop(str(instance_id)) ti.status = "stopped" ti.container_id = None await session.commit() await session.refresh(ti) return ti @router.post( "/projects/{project_id}/tool-instances/{instance_id}/start", response_model=ToolInstanceRead, ) async def start_tool_instance( project_id: UUID, instance_id: UUID, current_user: User = Depends(get_current_active_user), session: AsyncSession = Depends(get_db_session), ) -> ToolInstance: await _get_project_for_user(project_id, current_user, session) ti = await session.get(ToolInstance, instance_id) if not ti or ti.project_id != project_id: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found", ) tool_def = await session.get(ToolDefinition, ti.tool_definition_id) if not tool_def: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Tool definition not found", ) manifest = registry.get(tool_def.key) if not manifest: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Tool manifest '{tool_def.key}' not found in registry", ) user_slug = _get_user_slug(current_user) spawn_service = SpawnService() try: spawn_result = spawn_service.spawn( instance_id=str(ti.id), manifest=manifest, project_slug=ti.project.slug, user_slug=user_slug, ) except SpawnError as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to spawn container: {e}", ) from e ti.container_id = spawn_result["container_id"] ti.subdomain = spawn_result["subdomain"] ti.traefik_labels = spawn_result["traefik_labels"] ti.status = spawn_service.get_status(str(ti.id)) await session.commit() await session.refresh(ti) return ti @router.get( "/projects/{project_id}/tool-instances/{instance_id}/status", response_model=dict, ) async def get_tool_instance_status( project_id: UUID, instance_id: UUID, current_user: User = Depends(get_current_active_user), session: AsyncSession = Depends(get_db_session), ) -> dict[str, str]: await _get_project_for_user(project_id, current_user, session) ti = await session.get(ToolInstance, instance_id) if not ti or ti.project_id != project_id: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found", ) spawn_service = SpawnService() container_status = spawn_service.get_status(str(instance_id)) if ti.status != container_status: ti.status = container_status await session.commit() return { "instance_id": str(instance_id), "status": container_status, "subdomain": ti.subdomain or "", "container_id": ti.container_id or "", } @router.get("/auth/validate", status_code=status.HTTP_200_OK) async def validate_auth_for_traefik( current_user: User = Depends(get_current_active_user), ) -> dict[str, str]: return {"status": "ok", "user_id": str(current_user.id)}