diff --git a/apps/api/src/api/project/git_repositories.py b/apps/api/src/api/project/git_repositories.py index 394366f..3e9b2e0 100644 --- a/apps/api/src/api/project/git_repositories.py +++ b/apps/api/src/api/project/git_repositories.py @@ -1,11 +1,8 @@ import logging import os import shutil -import subprocess import uuid - from fastapi import APIRouter, Depends, HTTPException, Response, status -from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -15,21 +12,35 @@ from src.auth.dependencies import ( get_current_user_id, get_db_session, ) -from src.config import Settings -from src.models import GitRepository -from src.models import SSHKey -from src.schemas.project import ( +from src.models import GitRepository, SSHKey +from src.schemas.project.git_repository import ( + BranchCreateRequest, + BranchesResponse, + CheckoutRequest, + CommitRequest, + CommitResponse, + FetchResponse, + FileContentResponse, + FileListResponse, + FileUpdateRequest, + FileUpdateResponse, GitRepositoryCreate, GitRepositoryResponse, + MergeRequest, + MergeResponse, + PullResponse, + PushResponse, + StatusResponse, + UpdateSSHKeyRequest, URLParseRequest, URLParseResponse, - UpdateSSHKeyRequest, ) -from src.utils.git_files import ( - commit_file, - get_file_content, - list_branches, - list_tree, +from src.services.git.operations import ( + clone_working_repository, + get_repo_path, + init_working_repository, + list_remote_branches, + preflight_remote_repository, ) from src.utils.git_control import ( checkout_branch, @@ -42,94 +53,80 @@ from src.utils.git_control import ( pull, push, ) +from src.utils.git_files import commit_file, get_file_content, list_branches, list_tree from src.utils.git_history import get_commit_detail, get_commit_history from src.utils.git_url_parser import parse_git_url -from src.services.git.operations import ( - build_provider_clone_url, - clone_working_repository, - get_repo_path, - init_working_repository, - preflight_remote_repository, -) -from src.services.shared.ssh_keys import _get_fernet router = APIRouter(prefix="/projects", tags=["git-repositories"]) - logger = logging.getLogger(__name__) -@router.get( - "/repositories", - response_model=list[GitRepositoryResponse], - summary="List all user repositories", - description="List all git repositories owned by the user, including external repositories not tied to any project.", -) +async def _get_repo( + session: AsyncSession, user_id: uuid.UUID, project_id: uuid.UUID, repo_id: uuid.UUID +) -> GitRepository: + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + 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 + + +async def _get_repo_on_disk( + session: AsyncSession, user_id: uuid.UUID, project_id: uuid.UUID, repo_id: uuid.UUID +) -> GitRepository: + repo = await _get_repo(session, user_id, project_id, repo_id) + if not os.path.exists(repo.path): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + return repo + + +def _parse_remote_url(remote_url: str | None, force_original: bool) -> str | None: + if not remote_url or force_original: + return remote_url + parse_result = parse_git_url(remote_url) + if parse_result["needs_parsing"] and parse_result["base_url"]: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": "The provided URL appears to be a browser URL, not a git clone URL", + "suggested_url": parse_result["base_url"], + "original_url": remote_url, + "error_code": "URL_NEEDS_PARSING", + }, + ) + return parse_result.get("base_url") or remote_url + + +async def _commit_author(session: AsyncSession, user_id: uuid.UUID) -> tuple[str, str]: + user = await _get_user(session, user_id) + return user.name or "Unknown", user.email or "unknown@example.com" + + +@router.get("/repositories", response_model=list[GitRepositoryResponse]) async def list_user_repositories( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> list[GitRepository]: - """List all repositories owned by the user. - - Args: - user_id: ID of the authenticated user. - session: Database session. - - Returns: - List of all repositories owned by the user. - """ - result = await session.execute( - select(GitRepository).where(GitRepository.owner_id == user_id) - ) + """List all repositories owned by the user.""" + result = await session.execute(select(GitRepository).where(GitRepository.owner_id == user_id)) return list(result.scalars().all()) -@router.post( - "/repositories/parse-url", - response_model=URLParseResponse, - summary="Parse a git URL", - description="Parse a git URL and detect if it's a browser URL that needs correction.", -) +@router.post("/repositories/parse-url", response_model=URLParseResponse) async def parse_repository_url(data: URLParseRequest) -> URLParseResponse: - """Parse a git URL and detect if it's a browser URL that needs correction. - - Args: - data: Request containing the URL to parse. - - Returns: - Parsed URL information including whether it needs parsing and suggested corrections. - """ - result = parse_git_url(data.url) - return URLParseResponse(**result) + """Parse a git URL and detect if it's a browser URL that needs correction.""" + return URLParseResponse(**parse_git_url(data.url)) -@router.post( - "/repositories", - response_model=GitRepositoryResponse, - status_code=status.HTTP_201_CREATED, - summary="Create an external repository", - description="Create a new external git repository (not tied to any project). Can clone from remote URL.", -) +@router.post("/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED) async def create_external_repository( data: GitRepositoryCreate, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> GitRepository: - """Create a new external git repository. - - External repositories are not tied to any project and can be used - across all projects for config profile git mounts. - - Args: - data: Repository creation data including name and optional remote URL. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - The newly created external repository. - """ + """Create a new external git repository (not tied to any project).""" _user = await _get_user(session, user_id) - - # Check for duplicate name (external repos only) existing = await session.execute( select(GitRepository).where( GitRepository.project_id.is_(None), @@ -138,263 +135,104 @@ async def create_external_repository( ) ) if existing.scalar_one_or_none(): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="repository name already exists", - ) - - # Validate and potentially correct the URL - remote_url = data.remote_url - if remote_url and not data.force_original_url: - parse_result = parse_git_url(remote_url) - if parse_result["needs_parsing"] and parse_result["base_url"]: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "message": "The provided URL appears to be a browser URL, not a git clone URL", - "suggested_url": parse_result["base_url"], - "original_url": remote_url, - "error_code": "URL_NEEDS_PARSING", - }, - ) - if parse_result["base_url"]: - remote_url = parse_result["base_url"] - - # Validate SSH key if provided - ssh_key_id = None - ssh_key = None + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists") + remote_url = _parse_remote_url(data.remote_url, data.force_original_url) + ssh_key_id, ssh_key = None, None if data.ssh_key_id: try: ssh_key_id = uuid.UUID(data.ssh_key_id) except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="invalid ssh_key_id format", - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format") ssh_key = await session.get(SSHKey, ssh_key_id) if ssh_key is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") if ssh_key.user_id != user_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="ssh key does not belong to user", - ) - + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user") if remote_url: preflight_remote_repository(remote_url, ssh_key) - - # Create external repo with no project - repo = GitRepository( - name=data.name, - path="", # Will be set after clone - project_id=None, - owner_id=user_id, - remote_url=remote_url, - ssh_key_id=ssh_key_id, - ) + repo = GitRepository(name=data.name, path="", project_id=None, owner_id=user_id, remote_url=remote_url, ssh_key_id=ssh_key_id) session.add(repo) await session.flush() - - # Set path and optionally clone repo_path = f"/data/repos/external/{user_id}/{repo.id}" repo.path = repo_path - if remote_url: try: clone_working_repository(remote_url, repo_path, ssh_key) repo.is_mirror = False except Exception as exc: await session.rollback() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to clone repository: {exc}", - ) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}") else: - # Initialize empty repo - os.makedirs(repo_path, exist_ok=True) - subprocess.run(["git", "init", repo_path], check=True, capture_output=True) + init_working_repository(repo_path) repo.is_mirror = False - await session.commit() return repo -@router.get( - "/{project_id}/repositories", - response_model=list[GitRepositoryResponse], - summary="List repositories", - description="List all git repositories in a project.", -) +@router.get("/{project_id}/repositories", response_model=list[GitRepositoryResponse]) async def list_repositories( project_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> list[GitRepository]: - """List all repositories in a project. - - Args: - project_id: UUID of the project. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - List of repositories in the project. - """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) - - result = await session.execute( - select(GitRepository).where(GitRepository.project_id == project_id) - ) + result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id)) return list(result.scalars().all()) -@router.delete( - "/{project_id}/repositories/{repo_id}", - status_code=status.HTTP_204_NO_CONTENT, - summary="Delete a repository", - description="Delete a git repository from the project and remove it from disk.", -) +@router.delete("/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_repository( - project_id: uuid.UUID, - repo_id: uuid.UUID, + project_id: uuid.UUID, repo_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> Response: - """Delete a repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository to delete. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Empty response with 204 status code. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - # Remove from disk + repo = await _get_repo(session, user_id, project_id, repo_id) if os.path.exists(repo.path): shutil.rmtree(repo.path) - await session.delete(repo) await session.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) -@router.post( - "/{project_id}/repositories", - response_model=GitRepositoryResponse, - status_code=status.HTTP_201_CREATED, - summary="Create a repository", - description="Create a new git repository in a project. Can clone from remote or initialize a working repository.", -) +@router.post("/{project_id}/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED) async def create_repository( project_id: uuid.UUID, data: GitRepositoryCreate, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> GitRepository: - """Create a new git repository. - - Args: - project_id: UUID of the project. - data: Repository creation data including name and optional remote URL. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - The newly created repository. - """ + """Create a new git repository in a project.""" _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) - - # Check for duplicate name existing = await session.execute( - select(GitRepository).where( - GitRepository.project_id == project_id, - GitRepository.name == data.name, - ) + select(GitRepository).where(GitRepository.project_id == project_id, GitRepository.name == data.name) ) if existing.scalar_one_or_none(): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="repository name already exists", - ) - - # Validate and potentially correct the URL - remote_url = data.remote_url - if remote_url and not data.force_original_url: - parse_result = parse_git_url(remote_url) - if parse_result["needs_parsing"] and parse_result["base_url"]: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "message": "The provided URL appears to be a browser URL, not a git clone URL", - "suggested_url": parse_result["base_url"], - "original_url": remote_url, - "error_code": "URL_NEEDS_PARSING", - }, - ) - # Use base_url if it was extracted (for URLs without .git suffix) - if parse_result["base_url"]: - remote_url = parse_result["base_url"] - - # Validate SSH key if provided - ssh_key_id = None - ssh_key = None + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists") + remote_url = _parse_remote_url(data.remote_url, data.force_original_url) + ssh_key_id, ssh_key = None, None if data.ssh_key_id: try: ssh_key_id = uuid.UUID(data.ssh_key_id) except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="invalid ssh_key_id format", - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format") ssh_key = await session.get(SSHKey, ssh_key_id) if ssh_key is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") if ssh_key.user_id != user_id and ssh_key.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="ssh key does not belong to user or project", - ) - + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project") if remote_url: preflight_remote_repository(remote_url, ssh_key) - repo_path = get_repo_path(user_id, project_id, data.name) - - # Ensure parent directory exists os.makedirs(os.path.dirname(repo_path), exist_ok=True) - if remote_url: clone_working_repository(remote_url, repo_path, ssh_key) else: init_working_repository(repo_path) - repo = GitRepository( - name=data.name, - path=repo_path, - project_id=project_id, - owner_id=user_id, - is_mirror=False, - remote_url=remote_url, - ssh_key_id=ssh_key_id, + name=data.name, path=repo_path, project_id=project_id, owner_id=user_id, + is_mirror=False, remote_url=remote_url, ssh_key_id=ssh_key_id, ) session.add(repo) await session.commit() @@ -402,644 +240,160 @@ async def create_repository( return repo -@router.patch( - "/{project_id}/repositories/{repo_id}/ssh-key", - response_model=GitRepositoryResponse, - summary="Update repository SSH key", - description="Update the SSH key associated with a repository.", -) +@router.patch("/{project_id}/repositories/{repo_id}/ssh-key", response_model=GitRepositoryResponse) async def update_repository_ssh_key( - project_id: uuid.UUID, - repo_id: uuid.UUID, - data: UpdateSSHKeyRequest, + project_id: uuid.UUID, repo_id: uuid.UUID, data: UpdateSSHKeyRequest, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> GitRepository: - """Update the SSH key for a repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - data: Update data containing the new SSH key ID. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - The updated repository. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - # Validate SSH key if provided + repo = await _get_repo(session, user_id, project_id, repo_id) if data.ssh_key_id: try: ssh_key_id = uuid.UUID(data.ssh_key_id) except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="invalid ssh_key_id format", - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format") ssh_key = await session.get(SSHKey, ssh_key_id) if ssh_key is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") if ssh_key.user_id != user_id and ssh_key.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="ssh key does not belong to user or project", - ) - + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project") repo.ssh_key_id = ssh_key_id else: repo.ssh_key_id = None - await session.commit() await session.refresh(repo) return repo -@router.get( - "/{project_id}/repositories/{repo_id}/history", - summary="Get repository history", - description="Get commit history for a repository with optional branch filtering.", -) +@router.get("/{project_id}/repositories/{repo_id}/history") async def get_repository_history( - project_id: uuid.UUID, - repo_id: uuid.UUID, - view: str = "graph", - branch: str | None = None, - limit: int = 100, - offset: int = 0, + project_id: uuid.UUID, repo_id: uuid.UUID, + view: str = "graph", branch: str | None = None, limit: int = 100, offset: int = 0, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get commit history for a repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - view: View type for history display (default: graph). - branch: Optional branch name to filter commits. - limit: Maximum number of commits to return (default: 100). - offset: Number of commits to skip (default: 0). - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary containing commit history data. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - + repo = await _get_repo_on_disk(session, user_id, project_id, repo_id) try: - history = get_commit_history( - repo.path, branch=branch, limit=limit, offset=offset - ) - return history + return get_commit_history(repo.path, branch=branch, limit=limit, offset=offset) except RuntimeError as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) - ) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) -@router.get( - "/{project_id}/repositories/{repo_id}/commits/{commit_hash}", - summary="Get commit details", - description="Get detailed information about a specific commit.", -) +@router.get("/{project_id}/repositories/{repo_id}/commits/{commit_hash}") async def get_repository_commit( - project_id: uuid.UUID, - repo_id: uuid.UUID, - commit_hash: str, + project_id: uuid.UUID, repo_id: uuid.UUID, commit_hash: str, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get detailed information about a specific commit. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - commit_hash: Hash of the commit to retrieve. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary containing commit details. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - + repo = await _get_repo_on_disk(session, user_id, project_id, repo_id) try: - detail = get_commit_detail(repo.path, commit_hash) - return detail + return get_commit_detail(repo.path, commit_hash) except (RuntimeError, ValueError) as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -# File browsing endpoints - - -class FileListResponse(BaseModel): - path: str - branch: str - entries: list[dict] - - -class FileContentResponse(BaseModel): - path: str - branch: str - content: str - size: int - encoding: str - language: str | None - is_binary: bool - last_commit: dict | None - - -class BranchesResponse(BaseModel): - branches: list[dict] - default_branch: str - - -class FileUpdateRequest(BaseModel): - path: str - branch: str - content: str - commit_message: str - - -class FileUpdateResponse(BaseModel): - commit_hash: str - message: str - branch: str - - -@router.get( - "/{project_id}/repositories/{repo_id}/files", - response_model=FileListResponse, - summary="List repository files", - description="List files and directories in a repository path.", -) +@router.get("/{project_id}/repositories/{repo_id}/files", response_model=FileListResponse) async def list_repository_files( - project_id: uuid.UUID, - repo_id: uuid.UUID, - branch: str = "main", - path: str = "", + project_id: uuid.UUID, repo_id: uuid.UUID, branch: str = "main", path: str = "", user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> FileListResponse: - """List files and directories in a repository path. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - branch: Branch name to browse (default: main). - path: Directory path within the repository (default: root). - user_id: ID of the authenticated user. - session: Database session. - - Returns: - List of files and directories in the specified path. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - + repo = await _get_repo_on_disk(session, user_id, project_id, repo_id) try: entries = list_tree(repo.path, branch=branch, path=path) - return FileListResponse( - path=path, - branch=branch, - entries=[ - { - "name": e.name, - "type": e.type, - "path": e.path, - "size": e.size, - "mode": e.mode, - "last_commit": e.last_commit, - } - for e in entries - ], - ) + return FileListResponse(path=path, branch=branch, entries=[ + {"name": e.name, "type": e.type, "path": e.path, "size": e.size, "mode": e.mode, "last_commit": e.last_commit} + for e in entries + ]) except RuntimeError as e: - logger.error( - "Failed to list files for repo %s (path=%s, branch=%s): %s", - repo_id, - path, - branch, - str(e), - exc_info=True, - ) + logger.error("Failed to list files for repo %s: %s", repo_id, str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -@router.get( - "/{project_id}/repositories/{repo_id}/files/content", - response_model=FileContentResponse, - summary="Get file content", - description="Get the content of a file in a repository.", -) +@router.get("/{project_id}/repositories/{repo_id}/files/content", response_model=FileContentResponse) async def get_repository_file_content( - project_id: uuid.UUID, - repo_id: uuid.UUID, - branch: str, - path: str, + project_id: uuid.UUID, repo_id: uuid.UUID, branch: str, path: str, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> FileContentResponse: - """Get the content of a file. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - branch: Branch name where the file is located. - path: File path within the repository. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - File content and metadata. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - + repo = await _get_repo_on_disk(session, user_id, project_id, repo_id) try: - file_content = get_file_content(repo.path, branch=branch, path=path) - return FileContentResponse( - path=file_content.path, - branch=file_content.branch, - content=file_content.content, - size=file_content.size, - encoding=file_content.encoding, - language=file_content.language, - is_binary=file_content.is_binary, - last_commit=file_content.last_commit, - ) + fc = get_file_content(repo.path, branch=branch, path=path) + return FileContentResponse(path=fc.path, branch=fc.branch, content=fc.content, size=fc.size, + encoding=fc.encoding, language=fc.language, is_binary=fc.is_binary, last_commit=fc.last_commit) except FileNotFoundError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="file not found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found") except RuntimeError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -@router.get( - "/{project_id}/repositories/{repo_id}/branches", - response_model=BranchesResponse, - summary="List branches", - description="List all branches in the repository.", -) +@router.get("/{project_id}/repositories/{repo_id}/branches", response_model=BranchesResponse) async def get_repository_branches( - project_id: uuid.UUID, - repo_id: uuid.UUID, + project_id: uuid.UUID, repo_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> BranchesResponse: - """List all branches in the repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - List of branches and the default branch name. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - # Try local repo first (.git subdir for normal repos, HEAD for bare) - is_valid_git_repo = os.path.isdir( - os.path.join(repo.path, ".git") - ) or os.path.isfile(os.path.join(repo.path, "HEAD")) - - if is_valid_git_repo: + repo = await _get_repo(session, user_id, project_id, repo_id) + is_valid = os.path.isdir(os.path.join(repo.path, ".git")) or os.path.isfile(os.path.join(repo.path, "HEAD")) + if is_valid: try: branches, default_branch = list_branches(repo.path) - return BranchesResponse( - branches=[ - { - "name": b.name, - "is_default": b.is_default, - "last_commit": b.last_commit, - } - for b in branches - ], - default_branch=default_branch, - ) + return BranchesResponse(branches=[ + {"name": b.name, "is_default": b.is_default, "last_commit": b.last_commit} for b in branches + ], default_branch=default_branch) except RuntimeError as e: - logger.error( - "Failed to list branches for repo %s: %s", - repo_id, - str(e), - exc_info=True, - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) - ) from e - - # Local repo missing/corrupt — try remote if available + logger.error("Failed to list branches for repo %s: %s", repo_id, str(e)) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) from e if repo.remote_url: - ssh_key = None - if repo.ssh_key_id: - ssh_key = await session.get(SSHKey, repo.ssh_key_id) - - ssh_result = prepare_ssh_env(ssh_key) - env = None - key_path = None - if ssh_result: - env, key_path = ssh_result - + ssh_key = await session.get(SSHKey, repo.ssh_key_id) if repo.ssh_key_id else None try: - result = subprocess.run( - ["git", "ls-remote", "--heads", repo.remote_url], - capture_output=True, - text=True, - timeout=30, - env={**os.environ, **env} if env else None, - ) - if result.returncode == 0: - remote_branches = [] - default_branch = "main" - for line in result.stdout.strip().split("\n"): - if line: - parts = line.split("\t") - if len(parts) == 2: - ref = parts[1] - if ref.startswith("refs/heads/"): - branch_name = ref[len("refs/heads/") :] - remote_branches.append(branch_name) - if branch_name in ("main", "master"): - default_branch = branch_name - if remote_branches: - return BranchesResponse( - branches=[ - { - "name": b, - "is_default": b == default_branch, - "last_commit": None, - } - for b in remote_branches - ], - default_branch=default_branch, - ) - else: - logger.warning( - "ls-remote returned %d for repo %s: %s", - result.returncode, - repo_id, - result.stderr, - ) - except subprocess.TimeoutExpired: - logger.warning("ls-remote timed out for repo %s", repo_id) - except Exception as e: - logger.warning("ls-remote failed for repo %s: %s", repo_id, str(e)) - finally: - if key_path and os.path.exists(key_path): - os.unlink(key_path) - - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="repository not found on disk — re-clone or re-create the repository", - ) + remote_branches, default_branch = list_remote_branches(repo.remote_url, ssh_key) + if remote_branches: + return BranchesResponse(branches=[ + {"name": b, "is_default": b == default_branch, "last_commit": None} for b in remote_branches + ], default_branch=default_branch) + except RuntimeError: + pass + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk — re-clone or re-create the repository") -@router.post( - "/{project_id}/repositories/{repo_id}/files/content", - response_model=FileUpdateResponse, - summary="Update file content", - description="Update a file and create a commit.", -) +@router.post("/{project_id}/repositories/{repo_id}/files/content", response_model=FileUpdateResponse) async def update_repository_file( - project_id: uuid.UUID, - repo_id: uuid.UUID, - data: FileUpdateRequest, + project_id: uuid.UUID, repo_id: uuid.UUID, data: FileUpdateRequest, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> FileUpdateResponse: - """Update a file and create a commit. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - data: File update data including path, branch, content, and commit message. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Commit information for the file update. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - - # Get user info for commit - user = await _get_user(session, user_id) - author_name = user.name or "Unknown" - author_email = user.email or "unknown@example.com" - + repo = await _get_repo_on_disk(session, user_id, project_id, repo_id) + author_name, author_email = await _commit_author(session, user_id) try: - commit_hash = commit_file( - repo_path=repo.path, - branch=data.branch, - path=data.path, - content=data.content, - commit_message=data.commit_message, - author_name=author_name, - author_email=author_email, - ) - return FileUpdateResponse( - commit_hash=commit_hash, - message=data.commit_message, - branch=data.branch, - ) + commit_hash = commit_file(repo.path, data.branch, data.path, data.content, data.commit_message, author_name, author_email) + return FileUpdateResponse(commit_hash=commit_hash, message=data.commit_message, branch=data.branch) except RuntimeError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -# Git Control Endpoints - - -class StatusResponse(BaseModel): - branch: str - modified: list[str] - added: list[str] - deleted: list[str] - untracked: list[str] - renamed: list[str] - ahead: int - behind: int - - -@router.get( - "/{project_id}/repositories/{repo_id}/status", - response_model=StatusResponse, - summary="Get repository status", - description="Get the working directory status including modified, added, and deleted files.", -) +@router.get("/{project_id}/repositories/{repo_id}/status", response_model=StatusResponse) async def get_repository_status( - project_id: uuid.UUID, - repo_id: uuid.UUID, + project_id: uuid.UUID, repo_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> StatusResponse: - """Get the working directory status. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Repository status including branch, modified files, and ahead/behind counts. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - + repo = await _get_repo_on_disk(session, user_id, project_id, repo_id) try: - status_result = get_status(repo.path) - return StatusResponse( - branch=status_result.branch, - modified=status_result.modified, - added=status_result.added, - deleted=status_result.deleted, - untracked=status_result.untracked, - renamed=status_result.renamed, - ahead=status_result.ahead, - behind=status_result.behind, - ) + s = get_status(repo.path) + return StatusResponse(branch=s.branch, modified=s.modified, added=s.added, deleted=s.deleted, + untracked=s.untracked, renamed=s.renamed, ahead=s.ahead, behind=s.behind) except RuntimeError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -class BranchCreateRequest(BaseModel): - name: str - base_branch: str = "HEAD" - - -class CheckoutRequest(BaseModel): - branch: str - - -@router.post( - "/{project_id}/repositories/{repo_id}/branches", - summary="Create a branch", - description="Create a new branch in the repository.", -) +@router.post("/{project_id}/repositories/{repo_id}/branches") async def create_repository_branch( - project_id: uuid.UUID, - repo_id: uuid.UUID, - data: BranchCreateRequest, + project_id: uuid.UUID, repo_id: uuid.UUID, data: BranchCreateRequest, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Create a new branch. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - data: Branch creation data including name and optional base branch. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with success message and branch name. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - + repo = await _get_repo_on_disk(session, user_id, project_id, repo_id) try: create_branch(repo.path, data.name, data.base_branch) return {"message": f"Branch '{data.name}' created", "branch": data.name} @@ -1047,46 +401,13 @@ async def create_repository_branch( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -@router.delete( - "/{project_id}/repositories/{repo_id}/branches/{branch_name}", - summary="Delete a branch", - description="Delete a branch from the repository.", -) +@router.delete("/{project_id}/repositories/{repo_id}/branches/{branch_name}") async def delete_repository_branch( - project_id: uuid.UUID, - repo_id: uuid.UUID, - branch_name: str, - force: bool = False, + project_id: uuid.UUID, repo_id: uuid.UUID, branch_name: str, force: bool = False, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Delete a branch. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - branch_name: Name of the branch to delete. - force: Whether to force delete the branch. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with success message. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - + repo = await _get_repo_on_disk(session, user_id, project_id, repo_id) try: delete_branch(repo.path, branch_name, force) return {"message": f"Branch '{branch_name}' deleted"} @@ -1094,44 +415,13 @@ async def delete_repository_branch( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -@router.post( - "/{project_id}/repositories/{repo_id}/checkout", - summary="Checkout a branch", - description="Checkout a branch in the repository.", -) +@router.post("/{project_id}/repositories/{repo_id}/checkout") async def checkout_repository_branch( - project_id: uuid.UUID, - repo_id: uuid.UUID, - data: CheckoutRequest, + project_id: uuid.UUID, repo_id: uuid.UUID, data: CheckoutRequest, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Checkout a branch. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - data: Checkout request containing the branch name. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with success message and checked out branch name. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - + repo = await _get_repo_on_disk(session, user_id, project_id, repo_id) try: checkout_branch(repo.path, data.branch) return {"message": f"Checked out branch '{data.branch}'", "branch": data.branch} @@ -1139,117 +429,28 @@ async def checkout_repository_branch( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -class CommitRequest(BaseModel): - message: str - files: list[str] | None = None - - -class CommitResponse(BaseModel): - commit_hash: str - message: str - - -@router.post( - "/{project_id}/repositories/{repo_id}/commit", - response_model=CommitResponse, - summary="Commit changes", - description="Commit changes to the repository.", -) +@router.post("/{project_id}/repositories/{repo_id}/commit", response_model=CommitResponse) async def commit_repository_changes( - project_id: uuid.UUID, - repo_id: uuid.UUID, - data: CommitRequest, + project_id: uuid.UUID, repo_id: uuid.UUID, data: CommitRequest, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> CommitResponse: - """Commit changes to the repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - data: Commit request containing message and optional files to commit. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Commit information including hash and message. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - - # Get user info for commit - user = await _get_user(session, user_id) - author_name = user.name or "Unknown" - author_email = user.email or "unknown@example.com" - + repo = await _get_repo_on_disk(session, user_id, project_id, repo_id) + author_name, author_email = await _commit_author(session, user_id) try: - commit_hash = commit_changes( - repo_path=repo.path, - message=data.message, - author_name=author_name, - author_email=author_email, - files=data.files, - ) - return CommitResponse( - commit_hash=commit_hash, - message=data.message, - ) + commit_hash = commit_changes(repo.path, data.message, author_name, author_email, data.files) + return CommitResponse(commit_hash=commit_hash, message=data.message) except RuntimeError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -class FetchResponse(BaseModel): - message: str - - -@router.post( - "/{project_id}/repositories/{repo_id}/fetch", - response_model=FetchResponse, - summary="Fetch from remote", - description="Fetch updates from the remote repository.", -) +@router.post("/{project_id}/repositories/{repo_id}/fetch", response_model=FetchResponse) async def fetch_repository( - project_id: uuid.UUID, - repo_id: uuid.UUID, + project_id: uuid.UUID, repo_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> FetchResponse: - """Fetch from remote. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Success message. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - + repo = await _get_repo_on_disk(session, user_id, project_id, repo_id) try: fetch(repo.path) return FetchResponse(message="Fetched from remote") @@ -1257,49 +458,13 @@ async def fetch_repository( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -class PullResponse(BaseModel): - message: str - - -@router.post( - "/{project_id}/repositories/{repo_id}/pull", - response_model=PullResponse, - summary="Pull from remote", - description="Pull updates from the remote repository.", -) +@router.post("/{project_id}/repositories/{repo_id}/pull", response_model=PullResponse) async def pull_repository( - project_id: uuid.UUID, - repo_id: uuid.UUID, - branch: str | None = None, + project_id: uuid.UUID, repo_id: uuid.UUID, branch: str | None = None, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> PullResponse: - """Pull updates from remote. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - branch: Optional branch name to pull. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Success message. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - + repo = await _get_repo_on_disk(session, user_id, project_id, repo_id) try: pull(repo.path, branch) return PullResponse(message="Pulled from remote") @@ -1307,49 +472,13 @@ async def pull_repository( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -class PushResponse(BaseModel): - message: str - - -@router.post( - "/{project_id}/repositories/{repo_id}/push", - response_model=PushResponse, - summary="Push to remote", - description="Push changes to the remote repository.", -) +@router.post("/{project_id}/repositories/{repo_id}/push", response_model=PushResponse) async def push_repository( - project_id: uuid.UUID, - repo_id: uuid.UUID, - branch: str | None = None, + project_id: uuid.UUID, repo_id: uuid.UUID, branch: str | None = None, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> PushResponse: - """Push changes to remote. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - branch: Optional branch name to push. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Success message. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - + repo = await _get_repo_on_disk(session, user_id, project_id, repo_id) try: push(repo.path, branch) return PushResponse(message="Pushed to remote") @@ -1357,66 +486,15 @@ async def push_repository( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -class MergeRequest(BaseModel): - source_branch: str - target_branch: str | None = None - message: str | None = None - - -class MergeResponse(BaseModel): - commit_hash: str - message: str - - -@router.post( - "/{project_id}/repositories/{repo_id}/merge", - response_model=MergeResponse, - summary="Merge branches", - description="Merge one branch into another.", -) +@router.post("/{project_id}/repositories/{repo_id}/merge", response_model=MergeResponse) async def merge_repository_branches( - project_id: uuid.UUID, - repo_id: uuid.UUID, - data: MergeRequest, + project_id: uuid.UUID, repo_id: uuid.UUID, data: MergeRequest, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> MergeResponse: - """Merge branches. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - data: Merge request containing source branch, optional target branch, and message. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Merge result with commit hash and message. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - + repo = await _get_repo_on_disk(session, user_id, project_id, repo_id) try: - commit_hash = merge( - repo_path=repo.path, - source_branch=data.source_branch, - target_branch=data.target_branch, - message=data.message, - ) - return MergeResponse( - commit_hash=commit_hash, - message=data.message or f"Merge {data.source_branch}", - ) + commit_hash = merge(repo.path, data.source_branch, data.target_branch, data.message) + return MergeResponse(commit_hash=commit_hash, message=data.message or f"Merge {data.source_branch}") except RuntimeError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) diff --git a/apps/api/src/api/tool/tool_instances.py b/apps/api/src/api/tool/tool_instances.py index 255e914..f4d57fa 100644 --- a/apps/api/src/api/tool/tool_instances.py +++ b/apps/api/src/api/tool/tool_instances.py @@ -1,12 +1,7 @@ """Tool instance API endpoints.""" -import asyncio -import glob as glob_module import logging -import os -import subprocess import uuid -from datetime import datetime import httpx from fastapi import ( @@ -26,83 +21,22 @@ from src.auth.dependencies import ( get_current_user_id, get_db_session, ) -from src.services.instance.event_bus import InstanceEventBus -from src.services.instance.lifecycle_hooks import publish_lifecycle_event -from src.models import ConfigProfile -from src.models import GitRepository -from src.models import SSHKey from src.models import ToolInstance -from src.models import ToolType -from src.services.git.clone import check_dirty_state, clone_repository -from src.services.config.config_profile_resolver import ( - ConfigProfileCycleError, - ResolvedProfile, - apply_resolved_profile, - expand_container_path, - resolve_profile, -) -from src.services.docker import ( - connect_container_to_network, - ensure_instance_directory, - execute_compose_command, - find_free_port, - get_backend_network_name, - get_container_id, - get_container_ip_on_network, - get_container_logs, - get_container_status, - is_container_on_network, - render_compose_template, - sort_volumes_by_specificity, - wait_for_container_running, - write_compose_file, - write_config_files, - write_env_file, -) -from src.services.shared.tunnel import ( - check_tunnel_health, - recreate_tunnel, - start_tunnel, - stop_tunnel, -) -from src.services.build.docker_build import build_image -from src.services.build.manifest_compiler import ( - compile_compose, - compile_dockerfile, - compile_entrypoint, - compute_image_tag, - deep_merge, - get_manifest_home_dir, - merge_with_config, - resolve_base, -) -from src.services.shared.permission_fixer import ( - apply_mount_permissions, - apply_ssh_permissions, -) -from src.services.shared.readiness_probe import execute_probe -from src.services.shared.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files +from src.services.docker import get_container_logs, get_container_status +from src.services.shared.tunnel import check_tunnel_health from src.schemas.tool import CreateInstanceRequest, StartInstanceRequest from src.services.tool.instance_service import ( - checkout_branch, - clone_git_repo, - ensure_backend_network_in_compose, - ensure_container_name_in_compose, - ensure_web_bind_address, - expand_glob_source, - modify_compose_file, - normalize_git_mount, - pull_repository_updates, - resolve_git_mount_mappings, - resolve_git_mounts, - resolve_single_git_mount, - sanitize_compose_file, - validate_config_profile, + create_tool_instance, + delete_tool_instance, + recreate_instance_tunnel, + restart_tool_instance, + start_tool_instance, + stop_tool_instance, ) - logger = logging.getLogger(__name__) -_event_bus = InstanceEventBus() + +router = APIRouter(prefix="/projects", tags=["tool-instances"]) @router.post( @@ -117,400 +51,27 @@ async def create_instance( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Create a new tool instance for a repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - tool_type_id: UUID of the tool type to instantiate. - display_name: Optional display name for the instance. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with instance details. - """ - logger.debug( - "Creating instance: project_id=%s, repo_id=%s, tool_type_id=%s, display_name=%s", - project_id, - repo_id, - data.tool_type_id, - data.display_name, - ) _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - tool_type_id = uuid.UUID(data.tool_type_id) - tool_type = await session.get(ToolType, tool_type_id) - if tool_type is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found" - ) - - # Validate config profile if provided - selected_profile_id = await validate_config_profile( - session, data.config_profile_id, user_id, project_id, tool_type_id - ) - - # Resolve workspace if provided - workspace = None - workspace_id = None - if data.workspace_id: - from src.models import Workspace as WorkspaceModel - - try: - workspace_id = uuid.UUID(data.workspace_id) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid workspace_id format", - ) - workspace = await session.get(WorkspaceModel, workspace_id) - if workspace is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="workspace not found", - ) - if workspace.repo_id != repo_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="workspace does not belong to this repository", - ) - try: - # Validate clone mode requirements (legacy path) - if data.clone_mode == "clone" and not workspace: - if not repo.remote_url: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="repository does not have a remote URL for cloning", - ) - if not repo.ssh_key_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="repository must have an SSH key assigned for clone mode", - ) - - # Generate unique name - instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}" - - # Auto-generate display name with scoped numbering. - # When a workspace is provided, use workspace name + tool type. - # Otherwise fall back to repo name + tool type. - if data.display_name: - instance_display = data.display_name - else: - scope_name = workspace.name if workspace else repo.name - auto_name = f"{scope_name} / {tool_type.display_name}" - - if workspace: - count_query = ( - select(ToolInstance) - .where(ToolInstance.workspace_id == workspace_id) - .where(ToolInstance.tool_type_id == tool_type_id) - .where(ToolInstance.owner_id == user_id) - ) - else: - count_query = ( - select(ToolInstance) - .where(ToolInstance.repository_id == repo_id) - .where(ToolInstance.tool_type_id == tool_type_id) - .where(ToolInstance.owner_id == user_id) - ) - - result = await session.execute(count_query) - existing_count = len(result.scalars().all()) - if existing_count > 0: - instance_display = f"{auto_name} #{existing_count + 1}" - else: - instance_display = auto_name - - # Create instance directory - instance_dir = ensure_instance_directory(instance_name) - compose_path = os.path.join(instance_dir, "docker-compose.yml") - - # Find free port - tool_port = find_free_port() - - # Determine repo path based on workspace or clone mode - if workspace: - repo_path = workspace.path - elif data.clone_mode == "clone": - # Get SSH key for cloning - ssh_key = await session.get(SSHKey, repo.ssh_key_id) - if ssh_key is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="repository SSH key not found", - ) - - # Prepare SSH key for clone operation - ssh_key_path = None - try: - ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key) - ssh_key_path = os.path.join(ssh_dir, "id_ed25519") - - # Clone repository - clone_path = clone_repository( - remote_url=repo.remote_url, - ssh_key_path=ssh_key_path, - instance_dir=instance_dir, - branch=data.branch or "main", - ) - repo_path = clone_path - except Exception as exc: - logger.exception("Failed to clone repository: %s", exc) - cleanup_ssh_key_files(instance_dir) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to clone repository: {exc}", - ) - else: - repo_path = repo.path - - # Verify cloned repo has files - if data.clone_mode == "clone" and repo_path: - try: - repo_contents = os.listdir(repo_path) - if not repo_contents or ( - len(repo_contents) == 1 and repo_contents[0] == ".git" - ): - logger.error("Cloned repository at %s appears empty", repo_path) - raise RuntimeError("Cloned repository is empty") - logger.debug( - "Verified cloned repo at %s has %d items", - repo_path, - len(repo_contents), - ) - except Exception as exc: - logger.exception("Failed to verify cloned repository: %s", exc) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Cloned repository verification failed: {exc}", - ) - - # Create new local branch if requested - if data.clone_mode == "clone" and data.new_branch: - try: - result = subprocess.run( - ["git", "-C", repo_path, "checkout", "-b", data.new_branch], - capture_output=True, - text=True, - ) - if result.returncode != 0: - logger.error( - "Failed to create branch %s: %s", data.new_branch, result.stderr - ) - raise RuntimeError(f"Failed to create branch: {result.stderr}") - logger.debug( - "Created local branch %s in cloned repository", data.new_branch - ) - except Exception as exc: - logger.exception("Failed to create local branch: %s", exc) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to create local branch: {exc}", - ) - - # Handle based on definition type - if tool_type.definition_type == "dockerfile": - # Build image from Dockerfile - image_tag = f"headquarter/{instance_name}:latest".lower() - - if tool_type.dockerfile_template: - returncode, stdout, stderr = await asyncio.to_thread( - build_image, - instance_dir=instance_dir, - dockerfile=tool_type.dockerfile_template, - tag=image_tag, - build_context=tool_type.build_context, - ) - - if returncode != 0: - logger.error( - "Failed to build image for instance %s: %s", - instance_name, - stderr, - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to build Docker image: {stderr[:500]}", - ) - - logger.info( - "Successfully built image %s for instance %s", - image_tag, - instance_name, - ) - - # Generate compose for dockerfile-built image - # Only include ports if tool requires one (skip for terminal-only tools) - ports_section = ( - f""" ports: - - "{tool_port}:{tool_type.default_port}" -""" - if tool_type.default_port and tool_type.default_port > 0 - else "" - ) - - compose_content = f"""version: "3.8" -services: - app: - image: {image_tag} - container_name: {instance_name.lower()} - stdin_open: true - tty: true -{ports_section} volumes: - - {repo_path}:/workspace - restart: unless-stopped -""" - write_compose_file(instance_dir, compose_content) - - elif tool_type.definition_type == "manifest": - # Manifest-based: generate compose only; image built lazily on start - from src.models import ToolDefinitionManifest - - manifest_def = await session.get( - ToolDefinitionManifest, tool_type.manifest_id - ) - if not manifest_def: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Manifest definition not found for this tool type", - ) - - manifest = dict(manifest_def.manifest) - if manifest_def.base_definition_id: - base_def = await session.get( - ToolDefinitionManifest, manifest_def.base_definition_id - ) - if base_def: - manifest = resolve_base( - deep_merge(dict(base_def.manifest), manifest) - ) - - image_tag = compute_image_tag(tool_type.name, manifest) - - variables = { - "IMAGE_TAG": image_tag, - "INSTANCE_NAME": instance_name.lower(), - "INSTANCE_DIR": instance_dir, - "REPO_PATH": repo_path, - "SSH_PATH": "", - "TOOL_PORT": tool_port, - "EXTRA_ENV": {}, - "EXTRA_VOLUMES": [], - } - compose_content = compile_compose(manifest, variables) - write_compose_file(instance_dir, compose_content) - - else: - # Render compose template (legacy) - variables = { - "REPO_PATH": repo_path, - "INSTANCE_NAME": instance_name, - "INSTANCE_ID": instance_name, - "TOOL_NAME": instance_name, - "TOOL_PORT": tool_port, - "USER_ID": str(user_id), - "PROJECT_ID": str(project_id), - } - compose_content = render_compose_template( - tool_type.compose_template, variables - ) - - # Safety check: for clone mode, ensure repo is mounted in compose file - if data.clone_mode == "clone" and repo_path: - import yaml - - compose_data = yaml.safe_load(compose_content) - repo_mounted = False - if compose_data and "services" in compose_data: - for svc in compose_data["services"].values(): - volumes = svc.get("volumes", []) - for vol in volumes: - vol_str = str(vol) - if repo_path in vol_str: - repo_mounted = True - break - if repo_mounted: - break - - if not repo_mounted: - logger.warning( - "Compose template for tool type %s does not mount repo path; adding default mount", - tool_type.name, - ) - # Add default mount to first service - if compose_data and "services" in compose_data: - for svc in compose_data["services"].values(): - if "volumes" not in svc: - svc["volumes"] = [] - svc["volumes"].append(f"{repo_path}:/workspace") - break - compose_content = yaml.dump( - compose_data, default_flow_style=False - ) - - write_compose_file(instance_dir, compose_content) - - # Create database record - instance = ToolInstance( - name=instance_name, - display_name=instance_display, - tool_type_id=tool_type_id, - repository_id=repo_id, - project_id=project_id, - owner_id=user_id, - status="pending", - compose_path=compose_path, - port=tool_port, - workspace_id=workspace_id, - clone_mode=data.clone_mode, - branch=data.new_branch - if data.new_branch - else (data.branch if data.clone_mode == "clone" else None), - selected_config_profile_id=selected_profile_id, - ssh_key_ids=data.ssh_key_ids or None, - ) - session.add(instance) - await session.commit() - await session.refresh(instance) - await publish_lifecycle_event( - event_bus=_event_bus, - session=session, - instance=instance, - event_type="instance.created", - created_by=user_id, - status="pending", - message="Instance created", - ) - - return { - "id": str(instance.id), - "name": instance.name, - "display_name": instance.display_name, - "tool_type_id": str(instance.tool_type_id), - "status": instance.status, - "clone_mode": instance.clone_mode, - "branch": instance.branch, - "selected_config_profile_id": str(instance.selected_config_profile_id) - if instance.selected_config_profile_id - else None, - "created_at": instance.created_at.isoformat(), - } - except Exception as exc: - logger.exception("Failed to create instance: %s", exc) + instance = await create_tool_instance(session, user_id, project_id, repo_id, data) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) + except RuntimeError as exc: raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to create instance: {exc}", + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc) ) + return { + "id": str(instance.id), + "name": instance.name, + "display_name": instance.display_name, + "tool_type_id": str(instance.tool_type_id), + "status": instance.status, + "clone_mode": instance.clone_mode, + "branch": instance.branch, + "selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None, + "created_at": instance.created_at.isoformat(), + } @router.get( @@ -523,27 +84,10 @@ async def list_instances( repo_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), -) -> dict: - """List all instances for a repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary containing list of instances. - """ +) -> list[dict]: _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) - 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" - ) - result = await session.execute( select(ToolInstance) .where(ToolInstance.repository_id == repo_id) @@ -552,34 +96,19 @@ async def list_instances( ) instances = result.scalars().all() - instances_data = [] - for i in instances: - tool_type = await session.get(ToolType, i.tool_type_id) - instances_data.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.interface_type] if tool_type else [], - "status": i.status, - "url": i.url, - "port": i.port, - "clone_mode": i.clone_mode, - "branch": i.branch, - "ssh_key_ids": i.ssh_key_ids or [], - "created_at": i.created_at.isoformat(), - } - ) - - return {"instances": instances_data} + return [ + {"id": str(i.id), "name": i.name, "display_name": i.display_name, + "tool_type_id": str(i.tool_type_id), "status": i.status, "url": i.url, + "port": i.port, "container_id": i.container_id, + "created_at": i.created_at.isoformat() if i.created_at else None} + for i in instances + ] @router.get( "/{project_id}/repositories/{repo_id}/instances/{instance_id}", summary="Get instance", - description="Get a specific instance with real-time status from Docker.", + description="Get details for a specific tool instance.", ) async def get_instance( project_id: uuid.UUID, @@ -588,18 +117,6 @@ async def get_instance( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get a specific instance with real-time status. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with instance details and current status. - """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -609,16 +126,9 @@ async def get_instance( status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" ) - # Get real-time status from Docker + docker_status = None if instance.container_id: docker_status = get_container_status(instance.container_id) - if docker_status == "running" and instance.status != "running": - instance.status = "running" - await session.commit() - elif docker_status == "exited" and instance.status == "running": - instance.status = "stopped" - instance.last_stopped_at = datetime.now() - await session.commit() return { "id": str(instance.id), @@ -626,22 +136,20 @@ async def get_instance( "display_name": instance.display_name, "tool_type_id": str(instance.tool_type_id), "status": instance.status, - "container_id": instance.container_id, - "compose_path": instance.compose_path, "url": instance.url, + "public_url": instance.public_url, "port": instance.port, + "container_id": instance.container_id, + "container_name": instance.container_name, + "compose_path": instance.compose_path, "clone_mode": instance.clone_mode, "branch": instance.branch, - "selected_config_profile_id": str(instance.selected_config_profile_id) - if instance.selected_config_profile_id - else None, - "last_started_at": instance.last_started_at.isoformat() - if instance.last_started_at - else None, - "last_stopped_at": instance.last_stopped_at.isoformat() - if instance.last_stopped_at - else None, - "created_at": instance.created_at.isoformat(), + "workspace_id": str(instance.workspace_id) if instance.workspace_id else None, + "selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None, + "ssh_key_ids": instance.ssh_key_ids, + "created_at": instance.created_at.isoformat() if instance.created_at else None, + "last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None, + "docker_status": docker_status, } @@ -658,663 +166,19 @@ async def start_instance( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Start a tool instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance to start. - data: Optional start configuration including config profile selection. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with status and URL of the running instance. - """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: + try: + return await start_tool_instance( + session, user_id, project_id, repo_id, instance_id, data + ) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) + except RuntimeError as exc: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc) ) - # Validate and store config profile selection - if data and data.config_profile_id is not None: - selected_profile_id = await validate_config_profile( - session, data.config_profile_id, user_id, project_id, instance.tool_type_id - ) - instance.selected_config_profile_id = selected_profile_id - await session.commit() - - # Store SSH key selection if provided - if data and data.ssh_key_ids is not None: - instance.ssh_key_ids = data.ssh_key_ids or None - await session.commit() - - if not instance.compose_path or not os.path.exists(instance.compose_path): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found" - ) - - instance.status = "building" - await session.commit() - logger.info("Starting instance %s (name=%s)", instance.id, instance.name) - - # Runtime overrides populated by config profiles - env_vars = {} - config_files = {} - port_override = None - start_command = None - working_directory = None - extra_volumes = [] - - # Fetch tool type early to determine home directory and container user - tool_type = await session.get(ToolType, instance.tool_type_id) - home_dir = "/root" - container_uid = 0 - container_gid = 0 - if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id: - from src.models import ToolDefinitionManifest - - manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id) - if manifest_def: - manifest = dict(manifest_def.manifest) - # Merge with base definition if referenced (user config is often in base) - if manifest_def.base_definition_id: - base_def = await session.get( - ToolDefinitionManifest, manifest_def.base_definition_id - ) - if base_def: - manifest = resolve_base( - deep_merge(dict(base_def.manifest), manifest) - ) - home_dir = get_manifest_home_dir(manifest) - user_cfg = manifest.get("user") - if user_cfg: - container_uid = user_cfg.get("uid", 0) - container_gid = user_cfg.get("gid", 0) - logger.debug( - "Manifest user resolved for instance %s: uid=%s, gid=%s, home=%s", - instance.id, - container_uid, - container_gid, - home_dir, - ) - - # Apply selected config profile if any - instance_dir = os.path.dirname(instance.compose_path) - if instance.selected_config_profile_id is not None: - try: - resolved = await resolve_profile( - session, instance.selected_config_profile_id - ) - profile_env, profile_files, profile_mounts, profile_hints = ( - apply_resolved_profile(instance_dir, resolved, home_dir) - ) - # Profile env vars override tool config env vars - env_vars.update(profile_env) - # Profile files are written by apply_resolved_profile - config_files.update(profile_files) - # Profile mounts are added to extra volumes - extra_volumes.extend(profile_mounts) - # Git repository mounts are resolved and added - git_mount_volumes = await resolve_git_mounts( - session, resolved, instance_dir, working_directory, home_dir - ) - extra_volumes.extend(git_mount_volumes) - # Profile runtime hints override tool config values - if profile_hints.get("start_command"): - start_command = profile_hints["start_command"] - if profile_hints.get("working_directory"): - working_directory = profile_hints["working_directory"] - if profile_hints.get("port_override"): - port_override = profile_hints["port_override"] - logger.debug( - "Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)", - resolved.profile_name, - instance.id, - len(profile_env), - len(profile_files), - len(profile_mounts), - len(git_mount_volumes), - ) - except ConfigProfileCycleError as exc: - logger.error( - "Cycle detected in config profile for instance %s: %s", instance.id, exc - ) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Config profile cycle detected: {exc}", - ) - else: - logger.debug("No config profile selected for instance %s", instance.id) - - # Write env file and config files - env_file_path = None - - if env_vars: - env_file_path = write_env_file(instance_dir, env_vars) - logger.debug("Wrote env file for instance %s: %s", instance.id, env_file_path) - - if config_files: - write_config_files(instance_dir, config_files) - logger.debug( - "Wrote %d config files for instance %s", len(config_files), instance.id - ) - - # Mount selected SSH keys into container home dir - if instance.ssh_key_ids: - from src.services.shared.ssh_keys import write_ssh_config, _sanitize_filename - - # Collect all valid keys first - ssh_keys_to_mount = [] - for key_id in instance.ssh_key_ids: - ssh_key = await session.get(SSHKey, uuid.UUID(key_id)) - if ssh_key and ssh_key.user_id == user_id: - ssh_keys_to_mount.append(ssh_key) - else: - logger.warning( - "SSH key %s not found or not authorized for user %s", - key_id, - user_id, - ) - - if ssh_keys_to_mount: - # Use a single shared .ssh directory so all keys are visible - ssh_dir = os.path.join(instance_dir, "mounts", "ssh", ".ssh") - os.makedirs(ssh_dir, exist_ok=True) - - key_filenames = [] - for ssh_key in ssh_keys_to_mount: - # Use sanitized key name as filename prefix to avoid collisions - key_name = _sanitize_filename(ssh_key.name) - # If multiple keys have the same name, append a short hash - base_filename = f"id_ed25519_{key_name}" - filename = base_filename - counter = 1 - while filename in key_filenames: - filename = f"{base_filename}_{counter}" - counter += 1 - key_filenames.append(filename) - - try: - prepare_ssh_key_files( - instance_dir, - ssh_key, - subdir="mounts/ssh/.ssh", - uid=container_uid, - gid=container_gid, - key_filename=filename, - write_config=False, - ) - logger.debug( - "Prepared SSH key %s as %s for instance %s", - ssh_key.name, - filename, - instance.id, - ) - except Exception as exc: - logger.error( - "Failed to prepare SSH key %s for instance %s: %s", - ssh_key.id, - instance.id, - exc, - ) - - # Write combined SSH config with all keys - try: - write_ssh_config( - ssh_dir, - key_filenames, - uid=container_uid, - gid=container_gid, - ) - except Exception as exc: - logger.error( - "Failed to write SSH config for instance %s: %s", - instance.id, - exc, - ) - - # Mount the single .ssh directory into container home - ssh_target = os.path.join(home_dir, ".ssh") - extra_volumes.append( - { - "source": ssh_dir, - "target": ssh_target, - "type": "bind", - } - ) - logger.debug( - "Mounted %d SSH key(s) for instance %s to %s", - len(ssh_keys_to_mount), - instance.id, - ssh_target, - ) - - # ── MANIFEST-BASED FLOW ────────────────────────────────────── - resolved_manifest = None - - if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id: - logger.info("Using manifest-based startup for instance %s", instance.id) - - # Determine repo path (workspace takes precedence) - repo_path = "" - if instance.workspace_id: - from src.models import Workspace as WorkspaceModel - - workspace = await session.get(WorkspaceModel, instance.workspace_id) - if workspace: - repo_path = workspace.path - else: - repo = await session.get(GitRepository, instance.repository_id) - repo_path = repo.path if repo else "" - if instance.clone_mode == "clone": - repo_path = os.path.join(instance_dir, "repo-clone") - - try: - ( - image_tag, - compose_content, - resolved_manifest, - _home_dir, - ) = await prepare_manifest_instance( - session=session, - instance=instance, - instance_dir=instance_dir, - repo_path=repo_path, - env_vars=env_vars, - extra_volumes=extra_volumes, - working_directory=working_directory, - ) - write_compose_file(instance_dir, compose_content) - logger.debug( - "Generated manifest-based compose for instance %s", instance.id - ) - except Exception as exc: - logger.exception( - "Manifest compilation failed for instance %s: %s", instance.id, exc - ) - instance.status = "error" - await session.commit() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Manifest compilation failed: {exc}", - ) - else: - # ── LEGACY FLOW ────────────────────────────────────────── - # Mount SSH key for clone-mode instances (skip for workspace-based) - if instance.clone_mode == "clone" and not instance.workspace_id: - repo = await session.get(GitRepository, instance.repository_id) - if repo and repo.ssh_key_id: - ssh_key = await session.get(SSHKey, repo.ssh_key_id) - if ssh_key: - try: - ssh_dir = prepare_ssh_key_files( - instance_dir, ssh_key, uid=0, gid=0 - ) - extra_volumes.append( - { - "source": ssh_dir, - "target": "/root/.ssh", - "type": "bind", - } - ) - logger.debug( - "Mounted SSH key for clone-mode instance %s", instance.id - ) - except Exception as exc: - logger.error( - "Failed to prepare SSH key for instance %s: %s", - instance.id, - exc, - ) - - # Modify compose file if needed (port override, start command, working dir, volumes) - if port_override or start_command or working_directory or extra_volumes: - modify_compose_file( - instance.compose_path, - port_override, - start_command, - working_directory, - extra_volumes, - home_dir, - ) - logger.debug("Modified compose file for instance %s", instance.id) - - # Sanitize compose file to remove invalid port mappings from old instances - sanitize_compose_file(instance.compose_path) - - # Auto-fix bind address for known web tools that default to localhost - if tool_type and tool_type.interface_type == "web": - ensure_web_bind_address( - instance.compose_path, tool_type.name, tool_type.default_port - ) - - # Ensure predictable container name for tunnel connectivity - ensure_container_name_in_compose(instance.compose_path, instance.name) - ensure_backend_network_in_compose(instance.compose_path) - - # Execute docker compose up with env file - logger.debug( - "Running docker compose up for instance %s (compose_path=%s)", - instance.id, - instance.compose_path, - ) - returncode, stdout, stderr = execute_compose_command( - instance.compose_path, "up", env_file=env_file_path - ) - logger.debug( - "Docker compose up completed for instance %s: returncode=%d, stdout=%s, stderr=%s", - instance.id, - returncode, - stdout[:200] if stdout else "", - stderr[:500] if stderr else "", - ) - - if returncode != 0: - instance.status = "error" - await session.commit() - logger.error("Failed to start instance %s: %s", instance.id, stderr) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"failed to start instance: {stderr}", - ) - - # Get container ID and name (use predictable name from compose) - expected_container_name = instance.name.lower() - container_id = get_container_id(expected_container_name) - if container_id: - instance.container_id = container_id - logger.debug("Container ID for instance %s: %s", instance.id, container_id) - - instance.container_name = expected_container_name - logger.debug( - "Container name for instance %s: %s", instance.id, expected_container_name - ) - - # Verify container reached running state - if instance.container_id: - instance.status = "starting" - instance.last_started_at = datetime.now() - await session.commit() - await publish_lifecycle_event( - event_bus=_event_bus, - session=session, - instance=instance, - event_type="instance.started", - created_by=user_id, - status="starting", - message="Container starting...", - ) - logger.debug("Instance %s: verifying container startup...", instance.id) - - startup_result = wait_for_container_running( - instance.container_id, timeout=30, interval=2.0 - ) - - if not startup_result["success"]: - # Container failed to start - error_msg = f"Container failed to start: status={startup_result['status']}" - if startup_result["exit_code"] is not None: - error_msg += f", exit_code={startup_result['exit_code']}" - - # Get logs for debugging - logs = get_container_logs(instance.container_id, tail=50) - - instance.status = "error" - await session.commit() - await publish_lifecycle_event( - event_bus=_event_bus, - session=session, - instance=instance, - event_type="instance.error", - created_by=user_id, - status="error", - message=error_msg, - metadata={ - "exit_code": startup_result["exit_code"], - "error_type": "container", - }, - ) - logger.error( - "Instance %s container startup failed after %.1fs: %s\nLogs:\n%s", - instance.id, - startup_result["waited_seconds"], - error_msg, - logs, - ) - return { - "status": "error", - "error": error_msg, - "logs": logs, - } - - logger.debug( - "Instance %s container started successfully after %.1fs", - instance.id, - startup_result["waited_seconds"], - ) - - # Apply mount permission fixes for manifest-based instances - if resolved_manifest and instance.container_id: - mounts = resolved_manifest.get("mounts", []) - if mounts: - logger.debug( - "Applying permission fixes for instance %s (%d mounts)", - instance.id, - len(mounts), - ) - permission_results = apply_mount_permissions( - instance.container_id, - mounts, - ) - for result in permission_results: - if not result["success"]: - logger.warning( - "Permission fix failed for mount %s on instance %s: %s", - result["mount_name"], - instance.id, - result["error"], - ) - - # Fix SSH key ownership/permissions inside the container - if instance.ssh_key_ids and instance.container_id: - container_user = ( - "root" - if home_dir == "/root" - else home_dir[6:] - if home_dir.startswith("/home/") - else "root" - ) - ssh_target = os.path.join(home_dir, ".ssh") - logger.debug( - "Applying SSH permissions for user %s on %s in instance %s", - container_user, - ssh_target, - instance.id, - ) - ssh_perm_result = apply_ssh_permissions( - instance.container_id, - ssh_target, - container_user, - ) - if not ssh_perm_result["success"]: - logger.warning( - "SSH permission fix failed for instance %s: %s", - instance.id, - ssh_perm_result["error"], - ) - - # Execute readiness probe if configured - tool_type = await session.get(ToolType, instance.tool_type_id) - if tool_type and instance.container_id: - # Determine probe command - probe_command = None - probe_timeout = 30 - probe_interval = 2 - - if tool_type.readiness_probe: - probe_config = tool_type.readiness_probe - probe_command = probe_config.get("command", "") - probe_timeout = probe_config.get("timeout", 30) - probe_interval = probe_config.get("interval", 2) - elif tool_type.interface_type == "web": - # Default probe for web tools - probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}" - probe_timeout = 30 - probe_interval = 2 - - if probe_command: - instance.status = "probing" - await session.commit() - logger.debug( - "Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d", - instance.id, - probe_command, - probe_timeout, - probe_interval, - ) - - success, probe_logs = await execute_probe( - container_id=instance.container_id, - command=probe_command, - timeout=probe_timeout, - interval=probe_interval, - ) - - # Store probe result - instance.probe_result = { - "success": success, - "command": probe_command, - "logs": probe_logs, - "timestamp": datetime.now().isoformat(), - } - - if not success: - instance.status = "unhealthy" - await session.commit() - await publish_lifecycle_event( - event_bus=_event_bus, - session=session, - instance=instance, - event_type="instance.health_changed", - created_by=user_id, - status="unhealthy", - message="Readiness probe failed", - metadata={"probe_output": "\n".join(probe_logs)}, - ) - logger.error( - "Readiness probe failed for instance %s after %ds: %s", - instance.id, - probe_timeout, - "\n".join(probe_logs), - ) - return { - "status": "unhealthy", - "error": f"Readiness probe failed after {probe_timeout}s", - "probe_logs": probe_logs, - } - - logger.info("Readiness probe succeeded for instance %s", instance.id) - - instance.status = "running" - await session.commit() - await publish_lifecycle_event( - event_bus=_event_bus, - session=session, - instance=instance, - event_type="instance.health_changed", - created_by=user_id, - status="running", - message="Container running", - metadata={"previous_status": "starting"}, - ) - logger.info("Instance %s is now running", instance.id) - - # Get tool type for default port - tool_type = await session.get(ToolType, instance.tool_type_id) - if not tool_type: - logger.error("Tool type %s not found", instance.tool_type_id) - instance.status = "error" - await session.commit() - await publish_lifecycle_event( - event_bus=_event_bus, - session=session, - instance=instance, - event_type="instance.error", - created_by=user_id, - status="error", - message=f"Tool type '{instance.tool_type_id}' not found", - ) - return { - "status": "error", - "error": f"Tool type '{instance.tool_type_id}' not found", - } - - logger.debug( - "Tool type for instance %s: name=%s, container_port=%s, interface_type=%s", - instance.id, - tool_type.name, - tool_type.default_port or 0, - tool_type.interface_type, - ) - - # Only create Cloudflare tunnel for web-enabled tools - if tool_type.interface_type == "web": - # Create temporary Cloudflare tunnel for public access - try: - logger.debug( - "Creating tunnel for instance %s (container_port=%d)", - instance.id, - tool_type.default_port or 0, - ) - tunnel_info = start_tunnel( - instance_name=instance.name, - container_port=tool_type.default_port or 0, - ) - instance.tunnel_id = tunnel_info["container_name"] - instance.public_url = tunnel_info["url"] - instance.url = tunnel_info["url"] - await session.commit() - logger.debug( - "Created tunnel for instance %s: container=%s, url=%s", - instance.id, - tunnel_info["container_name"], - tunnel_info["url"], - ) - except Exception as exc: - import traceback - - error_msg = str(exc) - error_trace = traceback.format_exc() - logger.error( - "Failed to create tunnel for instance %s: %s\nTraceback:\n%s", - instance.id, - error_msg, - error_trace, - ) - instance.status = "error" - instance.url = None - await session.commit() - return { - "status": "error", - "error": f"Failed to create tunnel: {error_msg}", - } - else: - # Terminal-only tool - no tunnel needed - logger.info( - "Instance %s is terminal-only (no web interface), skipping tunnel creation", - instance.id, - ) - instance.url = None - instance.public_url = None - await session.commit() - - return {"status": instance.status, "url": instance.url} - @router.post( "/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop", @@ -1328,61 +192,12 @@ async def stop_instance( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Stop a tool instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance to stop. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with the stopped status. - """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - # Stop Cloudflare tunnel if exists - if instance.tunnel_id: - try: - stop_tunnel(instance.name) - logger.debug( - "Stopped tunnel for instance %s (container=%s)", - instance.id, - instance.tunnel_id, - ) - except Exception as exc: - logger.warning( - "Failed to stop tunnel for instance %s: %s", instance.id, exc - ) - - if instance.compose_path and os.path.exists(instance.compose_path): - execute_compose_command(instance.compose_path, "stop") - - instance.status = "stopped" - instance.last_stopped_at = datetime.now() - instance.url = None - instance.public_url = None - instance.tunnel_id = None - await session.commit() - await publish_lifecycle_event( - event_bus=_event_bus, - session=session, - instance=instance, - event_type="instance.stopped", - created_by=user_id, - status="stopped", - message="Instance stopped", - ) - - return {"status": instance.status} + try: + return await stop_tool_instance(session, user_id, project_id, repo_id, instance_id) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) @router.post( @@ -1397,149 +212,19 @@ async def restart_instance( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Restart a tool instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance to restart. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with status and URL of the restarted instance. - """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: + try: + return await restart_tool_instance( + session, user_id, project_id, repo_id, instance_id + ) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) + except RuntimeError as exc: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc) ) - # Stop old tunnel if exists - if instance.tunnel_id: - try: - stop_tunnel(instance.name) - logger.debug( - "Stopped old tunnel for instance %s (container=%s)", - instance.id, - instance.tunnel_id, - ) - except Exception as exc: - logger.warning( - "Failed to stop old tunnel for instance %s: %s", instance.id, exc - ) - - # Re-apply stored config profile on restart - if instance.compose_path and os.path.exists(instance.compose_path): - instance_dir = os.path.dirname(instance.compose_path) - if instance.selected_config_profile_id is not None: - try: - resolved = await resolve_profile( - session, instance.selected_config_profile_id - ) - profile_env, profile_files, profile_mounts, profile_hints = ( - apply_resolved_profile(instance_dir, resolved) - ) - # Write env file with resolved profile env vars - if profile_env: - write_env_file(instance_dir, profile_env) - logger.debug( - "Re-applied config profile %s on restart for instance %s", - resolved.profile_name, - instance.id, - ) - except ConfigProfileCycleError as exc: - logger.error( - "Cycle detected in stored config profile for instance %s: %s", - instance.id, - exc, - ) - - # Re-apply compose fixes in case they were updated since last start - sanitize_compose_file(instance.compose_path) - tool_type = await session.get(ToolType, instance.tool_type_id) - if tool_type and tool_type.interface_type == "web": - ensure_web_bind_address( - instance.compose_path, tool_type.name, tool_type.default_port - ) - ensure_container_name_in_compose(instance.compose_path, instance.name) - ensure_backend_network_in_compose(instance.compose_path) - - returncode, stdout, stderr = execute_compose_command( - instance.compose_path, "restart" - ) - - if returncode == 0: - instance.status = "running" - instance.last_started_at = datetime.now() - - # Get tool type for default port - tool_type = await session.get(ToolType, instance.tool_type_id) - if not tool_type or not tool_type.default_port: - logger.error( - "Tool type %s has no default_port configured. Cannot create tunnel.", - instance.tool_type_id, - ) - instance.status = "error" - await session.commit() - return { - "status": "error", - "error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured", - } - - # Only create tunnel for web-enabled tools - if tool_type.interface_type == "web": - # Create new tunnel - try: - tunnel_info = start_tunnel( - instance_name=instance.name, - container_port=tool_type.default_port or 0, - ) - instance.tunnel_id = tunnel_info["container_name"] - instance.public_url = tunnel_info["url"] - instance.url = tunnel_info["url"] - logger.debug( - "Created new tunnel for instance %s: %s", - instance.id, - tunnel_info["url"], - ) - except Exception as exc: - logger.warning( - "Failed to create tunnel for instance %s: %s", - instance.id, - exc, - ) - instance.status = "error" - instance.url = None - await session.commit() - return { - "status": "error", - "error": f"Failed to create tunnel: {exc}", - } - else: - # Terminal-only tool - instance.url = None - instance.public_url = None - - await session.commit() - await publish_lifecycle_event( - event_bus=_event_bus, - session=session, - instance=instance, - event_type="instance.restarted", - created_by=user_id, - status="running", - message="Instance restarted", - ) - return {"status": instance.status, "url": instance.url} - - instance.status = "error" - await session.commit() - return {"status": instance.status} - @router.delete( "/{project_id}/repositories/{repo_id}/instances/{instance_id}", @@ -1554,84 +239,29 @@ async def delete_instance( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> None: - """Delete a tool instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance to delete. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - None with 204 status code. - """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: + try: + await delete_tool_instance( + session, user_id, project_id, repo_id, instance_id, force + ) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) + except RuntimeError as exc: + detail = str(exc) + if "uncommitted changes" in detail.lower(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": "Repository has uncommitted changes", + "changed_files": detail, + "force_required": True, + }, + ) raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail ) - # Check dirty state for clone-mode instances - if instance.clone_mode == "clone" and not force: - instance_dir = ( - os.path.dirname(instance.compose_path) if instance.compose_path else None - ) - if instance_dir: - clone_path = os.path.join(instance_dir, "repo-clone") - if os.path.exists(clone_path): - is_dirty, changed_files = check_dirty_state(clone_path) - if is_dirty: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail={ - "message": "Repository has uncommitted changes", - "changed_files": changed_files, - "force_required": True, - }, - ) - - # Stop Cloudflare tunnel if exists - if instance.tunnel_id: - try: - stop_tunnel(instance.name) - logger.debug( - "Stopped tunnel for instance %s (container=%s)", - instance.id, - instance.tunnel_id, - ) - except Exception as exc: - logger.warning( - "Failed to stop tunnel for instance %s: %s", instance.id, exc - ) - - # Stop and remove container - if instance.compose_path and os.path.exists(instance.compose_path): - execute_compose_command(instance.compose_path, "down") - - # Remove instance directory (includes clone and SSH keys) - if instance.compose_path: - instance_dir = os.path.dirname(instance.compose_path) - if os.path.exists(instance_dir): - import shutil - - shutil.rmtree(instance_dir) - - await publish_lifecycle_event( - event_bus=_event_bus, - session=session, - instance=instance, - event_type="instance.deleted", - created_by=user_id, - status="deleted", - message="Instance deleted", - ) - await session.delete(instance) - await session.commit() - @router.get( "/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs", @@ -1646,19 +276,6 @@ async def get_instance_logs( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get container logs for an instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance. - tail: Number of log lines to return (default: 100). - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary containing the container logs. - """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -1687,150 +304,17 @@ async def recreate_tunnel_endpoint( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Recreate the temporary tunnel for an instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with new URL and status. - """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) - - 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" - ) - - if instance.status != "running": - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="instance must be running to recreate tunnel", - ) - - tool_type = await session.get(ToolType, instance.tool_type_id) - if not tool_type: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Tool type not found for this instance", - ) - - expected_name = instance.name.lower() - logger.info( - "Recreate tunnel for instance %s (expected container name: %s, default_port: %s)", - instance.id, - expected_name, - tool_type.default_port, - ) - - # Find the tool container — try stored ID first, then fall back to name lookup - tool_container_id = instance.container_id - if tool_container_id: - logger.info("Using stored container_id: %s", tool_container_id) - else: - tool_container_id = get_container_id(expected_name) - if tool_container_id: - logger.info("Found container by name: %s", tool_container_id) - else: - logger.error("Container %s not found", expected_name) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Could not find running container for this instance", - ) - - # Ensure the tool container is on the backend network so the tunnel can reach it - network_name = get_backend_network_name() - on_network = is_container_on_network(tool_container_id, network_name) - logger.info( - "Container %s on network %s: %s", - tool_container_id, - network_name, - on_network, - ) - if not on_network: - logger.info( - "Connecting container %s to network %s", - tool_container_id, - network_name, - ) - connected = connect_container_to_network(tool_container_id, network_name) - logger.info("Network connect result: %s", connected) - - # Get the container's IP on the backend network - target_ip = get_container_ip_on_network(tool_container_id, network_name) - if target_ip: - target_url = f"http://{target_ip}:{tool_type.default_port or 0}" - logger.info( - "Tunnel target for instance %s: %s (IP %s on %s)", - instance.id, - target_url, - target_ip, - network_name, - ) - else: - target_url = f"http://{expected_name}:{tool_type.default_port or 0}" - logger.warning( - "Could not get container IP, falling back to name-based target: %s", - target_url, - ) - try: - tunnel_info = recreate_tunnel( - instance_name=instance.name, - container_port=tool_type.default_port or 0, - target_url=target_url, + return await recreate_instance_tunnel( + session, user_id, project_id, repo_id, instance_id ) - logger.info( - "Tunnel recreated: container=%s, url=%s", - tunnel_info["container_name"], - tunnel_info["url"], - ) - - # Verify the tunnel can actually reach the origin - health = check_tunnel_health(tunnel_info["url"], timeout=10) - logger.info( - "Tunnel health check: status=%s, code=%s, error=%s", - health.get("tunnel_status"), - health.get("status_code"), - health.get("error"), - ) - - # Also probe from inside the API container directly to the target - probe = subprocess.run( - [ - "curl", - "-s", - "-o", - "/dev/null", - "-w", - "%{http_code}", - "--max-time", - "5", - target_url, - ], - capture_output=True, - text=True, - ) - logger.info( - "Direct probe from API to %s: HTTP %s", target_url, probe.stdout.strip() - ) - - instance.tunnel_id = tunnel_info["container_name"] - instance.public_url = tunnel_info["url"] - instance.url = tunnel_info["url"] - await session.commit() - return {"status": "healthy", "url": instance.url} - except Exception as exc: - logger.exception("Failed to recreate tunnel for instance %s", instance.id) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) + except RuntimeError as exc: raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to recreate tunnel: {str(exc)}", + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc) ) @@ -1846,18 +330,6 @@ async def check_instance_tunnel_health( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Check health for an instance (container + tunnel). - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with container_status, tunnel_status, probe_status, and overall healthy flag. - """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -1867,12 +339,10 @@ async def check_instance_tunnel_health( status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" ) - # Check container status container_info = {"status": "not_found", "exit_code": None, "health": None} if instance.container_id: container_info = get_container_status(instance.container_id) - # Build response response = { "healthy": False, "container_status": container_info["status"], @@ -1883,39 +353,26 @@ async def check_instance_tunnel_health( "last_probe_output": None, "error": None, } - - # Determine probe status if instance.status == "probing": response["probe_status"] = "pending" elif instance.probe_result: - response["probe_status"] = ( - "success" if instance.probe_result.get("success") else "failed" - ) + response["probe_status"] = "success" if instance.probe_result.get("success") else "failed" response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", [])) - - # Check tunnel health if instance has a URL and is web-enabled if instance.url and instance.status in ("running", "unhealthy"): tunnel_health = check_tunnel_health(instance.url) response["tunnel_status"] = tunnel_health["tunnel_status"] response["tunnel_status_code"] = tunnel_health.get("status_code") if tunnel_health.get("error"): response["error"] = tunnel_health["error"] - - # Overall healthy: web tools need running container + healthy tunnel; - # terminal tools only need running container container_healthy = container_info["status"] == "running" if instance.url: - tunnel_healthy = response["tunnel_status"] == "healthy" - response["healthy"] = container_healthy and tunnel_healthy + response["healthy"] = container_healthy and response["tunnel_status"] == "healthy" else: response["healthy"] = container_healthy - - # If container is not running, override error message if not container_healthy: response["error"] = f"Container is {container_info['status']}" if container_info["exit_code"] is not None: response["error"] += f" (exit code: {container_info['exit_code']})" - return response @@ -1932,19 +389,6 @@ async def get_instance_events( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> list[dict]: - """Get lifecycle event history for an instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance. - limit: Maximum number of events to return (default: 50). - user_id: ID of the authenticated user. - session: Database session. - - Returns: - List of event dictionaries. - """ from sqlalchemy import select from src.models import InstanceEvent @@ -1978,47 +422,12 @@ async def get_instance_events( ] -@router.get( +@router.api_route( "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", + methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"], 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, @@ -2028,27 +437,12 @@ async def proxy_to_instance( 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, @@ -2061,22 +455,18 @@ async def proxy_to_instance( 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 + headers.pop("cookie", None) - # Forward the request try: async with httpx.AsyncClient() as client: body = await request.body() @@ -2095,9 +485,7 @@ async def proxy_to_instance( 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) diff --git a/apps/api/src/schemas/project/git_repository.py b/apps/api/src/schemas/project/git_repository.py index 7d4235c..936807a 100644 --- a/apps/api/src/schemas/project/git_repository.py +++ b/apps/api/src/schemas/project/git_repository.py @@ -45,3 +45,91 @@ class GitRepositoryResponse(BaseModel): class UpdateSSHKeyRequest(BaseModel): ssh_key_id: str | None = None + + +class FileListResponse(BaseModel): + path: str + branch: str + entries: list[dict] + + +class FileContentResponse(BaseModel): + path: str + branch: str + content: str + size: int + encoding: str + language: str | None + is_binary: bool + last_commit: dict | None + + +class BranchesResponse(BaseModel): + branches: list[dict] + default_branch: str + + +class FileUpdateRequest(BaseModel): + path: str + branch: str + content: str + commit_message: str + + +class FileUpdateResponse(BaseModel): + commit_hash: str + message: str + branch: str + + +class StatusResponse(BaseModel): + branch: str + modified: list[str] + added: list[str] + deleted: list[str] + untracked: list[str] + renamed: list[str] + ahead: int + behind: int + + +class BranchCreateRequest(BaseModel): + name: str + base_branch: str = "HEAD" + + +class CheckoutRequest(BaseModel): + branch: str + + +class CommitRequest(BaseModel): + message: str + files: list[str] | None = None + + +class CommitResponse(BaseModel): + commit_hash: str + message: str + + +class FetchResponse(BaseModel): + message: str + + +class PullResponse(BaseModel): + message: str + + +class PushResponse(BaseModel): + message: str + + +class MergeRequest(BaseModel): + source_branch: str + target_branch: str | None = None + message: str | None = None + + +class MergeResponse(BaseModel): + commit_hash: str + message: str diff --git a/apps/api/src/services/git/operations.py b/apps/api/src/services/git/operations.py index 39e372c..b00e8e8 100644 --- a/apps/api/src/services/git/operations.py +++ b/apps/api/src/services/git/operations.py @@ -167,3 +167,47 @@ def init_working_repository(repo_path: str) -> None: status_code=status.HTTP_400_BAD_REQUEST, detail=f"failed to set initial branch: {ref_result.stderr}", ) + + +def list_remote_branches(remote_url: str, ssh_key: SSHKey | None = None) -> tuple[list[str], str]: + """List branches from a remote repository via ls-remote. + + Returns: + Tuple of (branch_names, default_branch). + """ + ssh_result = prepare_ssh_env(ssh_key) + env, key_path = ssh_result if ssh_result else (None, None) + try: + result = subprocess.run( + ["git", "ls-remote", "--heads", remote_url], + capture_output=True, + text=True, + timeout=30, + env={**os.environ, **env} if env else None, + ) + if result.returncode != 0: + logger.warning("ls-remote returned %d: %s", result.returncode, result.stderr) + raise RuntimeError(f"ls-remote failed: {result.stderr}") + branches = [] + default_branch = "main" + for line in result.stdout.strip().split("\n"): + if not line: + continue + parts = line.split("\t") + if len(parts) == 2: + ref = parts[1] + if ref.startswith("refs/heads/"): + branch_name = ref[len("refs/heads/"):] + branches.append(branch_name) + if branch_name in ("main", "master"): + default_branch = branch_name + return branches, default_branch + except subprocess.TimeoutExpired: + logger.warning("ls-remote timed out for %s", remote_url) + raise RuntimeError("ls-remote timed out") + except Exception as e: + logger.warning("ls-remote failed for %s: %s", remote_url, str(e)) + raise RuntimeError(f"ls-remote failed: {e}") + finally: + if key_path and os.path.exists(key_path): + os.unlink(key_path) diff --git a/apps/api/src/services/tool/instance_service.py b/apps/api/src/services/tool/instance_service.py index bea3e35..f93520d 100644 --- a/apps/api/src/services/tool/instance_service.py +++ b/apps/api/src/services/tool/instance_service.py @@ -1182,3 +1182,980 @@ async def create_tool_instance( ) return instance + + +async def start_tool_instance( + session: AsyncSession, + user_id: uuid.UUID, + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, + data: "StartInstanceRequest | None", +) -> dict: + """Start a tool instance. + + Returns a dict with status and url. + Raises ValueError for invalid input, RuntimeError for internal failures. + """ + instance = await session.get(ToolInstance, instance_id) + if instance is None or instance.repository_id != repo_id: + raise ValueError("instance not found") + + # Validate and store config profile selection + if data and data.config_profile_id is not None: + selected_profile_id = await validate_config_profile( + session, data.config_profile_id, user_id, project_id, instance.tool_type_id + ) + instance.selected_config_profile_id = selected_profile_id + await session.commit() + + # Store SSH key selection if provided + if data and data.ssh_key_ids is not None: + instance.ssh_key_ids = data.ssh_key_ids or None + await session.commit() + + if not instance.compose_path or not os.path.exists(instance.compose_path): + raise ValueError("compose file not found") + + instance.status = "building" + await session.commit() + logger.info("Starting instance %s (name=%s)", instance.id, instance.name) + + # Runtime overrides populated by config profiles + env_vars = {} + config_files = {} + port_override = None + start_command = None + working_directory = None + extra_volumes = [] + + # Fetch tool type early to determine home directory and container user + tool_type = await session.get(ToolType, instance.tool_type_id) + home_dir = "/root" + container_uid = 0 + container_gid = 0 + if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id: + from src.models import ToolDefinitionManifest + + manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id) + if manifest_def: + manifest = dict(manifest_def.manifest) + if manifest_def.base_definition_id: + base_def = await session.get( + ToolDefinitionManifest, manifest_def.base_definition_id + ) + if base_def: + manifest = resolve_base( + deep_merge(dict(base_def.manifest), manifest) + ) + home_dir = get_manifest_home_dir(manifest) + user_cfg = manifest.get("user") + if user_cfg: + container_uid = user_cfg.get("uid", 0) + container_gid = user_cfg.get("gid", 0) + logger.debug( + "Manifest user resolved for instance %s: uid=%s, gid=%s, home=%s", + instance.id, + container_uid, + container_gid, + home_dir, + ) + + # Apply selected config profile if any + instance_dir = os.path.dirname(instance.compose_path) + if instance.selected_config_profile_id is not None: + try: + resolved = await resolve_profile( + session, instance.selected_config_profile_id + ) + profile_env, profile_files, profile_mounts, profile_hints = ( + apply_resolved_profile(instance_dir, resolved, home_dir) + ) + env_vars.update(profile_env) + config_files.update(profile_files) + extra_volumes.extend(profile_mounts) + git_mount_volumes = await resolve_git_mounts( + session, resolved, instance_dir, working_directory, home_dir + ) + extra_volumes.extend(git_mount_volumes) + if profile_hints.get("start_command"): + start_command = profile_hints["start_command"] + if profile_hints.get("working_directory"): + working_directory = profile_hints["working_directory"] + if profile_hints.get("port_override"): + port_override = profile_hints["port_override"] + logger.debug( + "Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)", + resolved.profile_name, + instance.id, + len(profile_env), + len(profile_files), + len(profile_mounts), + len(git_mount_volumes), + ) + except ConfigProfileCycleError as exc: + logger.error( + "Cycle detected in config profile for instance %s: %s", instance.id, exc + ) + raise ValueError(f"Config profile cycle detected: {exc}") + else: + logger.debug("No config profile selected for instance %s", instance.id) + + # Write env file and config files + env_file_path = None + + if env_vars: + env_file_path = write_env_file(instance_dir, env_vars) + logger.debug("Wrote env file for instance %s: %s", instance.id, env_file_path) + + if config_files: + write_config_files(instance_dir, config_files) + logger.debug( + "Wrote %d config files for instance %s", len(config_files), instance.id + ) + + # Mount selected SSH keys into container home dir + if instance.ssh_key_ids: + from src.services.shared.ssh_keys import write_ssh_config, _sanitize_filename + + ssh_keys_to_mount = [] + for key_id in instance.ssh_key_ids: + ssh_key = await session.get(SSHKey, uuid.UUID(key_id)) + if ssh_key and ssh_key.user_id == user_id: + ssh_keys_to_mount.append(ssh_key) + else: + logger.warning( + "SSH key %s not found or not authorized for user %s", + key_id, + user_id, + ) + + if ssh_keys_to_mount: + ssh_dir = os.path.join(instance_dir, "mounts", "ssh", ".ssh") + os.makedirs(ssh_dir, exist_ok=True) + + key_filenames = [] + for ssh_key in ssh_keys_to_mount: + key_name = _sanitize_filename(ssh_key.name) + base_filename = f"id_ed25519_{key_name}" + filename = base_filename + counter = 1 + while filename in key_filenames: + filename = f"{base_filename}_{counter}" + counter += 1 + key_filenames.append(filename) + + try: + prepare_ssh_key_files( + instance_dir, + ssh_key, + subdir="mounts/ssh/.ssh", + uid=container_uid, + gid=container_gid, + key_filename=filename, + write_config=False, + ) + logger.debug( + "Prepared SSH key %s as %s for instance %s", + ssh_key.name, + filename, + instance.id, + ) + except Exception as exc: + logger.error( + "Failed to prepare SSH key %s for instance %s: %s", + ssh_key.id, + instance.id, + exc, + ) + + try: + write_ssh_config( + ssh_dir, + key_filenames, + uid=container_uid, + gid=container_gid, + ) + except Exception as exc: + logger.error( + "Failed to write SSH config for instance %s: %s", + instance.id, + exc, + ) + + ssh_target = os.path.join(home_dir, ".ssh") + extra_volumes.append( + { + "source": ssh_dir, + "target": ssh_target, + "type": "bind", + } + ) + logger.debug( + "Mounted %d SSH key(s) for instance %s to %s", + len(ssh_keys_to_mount), + instance.id, + ssh_target, + ) + + # ── MANIFEST-BASED FLOW ────────────────────────────────────── + resolved_manifest = None + + if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id: + logger.info("Using manifest-based startup for instance %s", instance.id) + + repo_path = "" + if instance.workspace_id: + from src.models import Workspace as WorkspaceModel + + workspace = await session.get(WorkspaceModel, instance.workspace_id) + if workspace: + repo_path = workspace.path + else: + repo = await session.get(GitRepository, instance.repository_id) + repo_path = repo.path if repo else "" + if instance.clone_mode == "clone": + repo_path = os.path.join(instance_dir, "repo-clone") + + try: + ( + image_tag, + compose_content, + resolved_manifest, + _home_dir, + ) = await prepare_manifest_instance( + session=session, + instance=instance, + instance_dir=instance_dir, + repo_path=repo_path, + env_vars=env_vars, + extra_volumes=extra_volumes, + working_directory=working_directory, + ) + write_compose_file(instance_dir, compose_content) + logger.debug( + "Generated manifest-based compose for instance %s", instance.id + ) + except Exception as exc: + logger.exception( + "Manifest compilation failed for instance %s: %s", instance.id, exc + ) + instance.status = "error" + await session.commit() + raise RuntimeError(f"Manifest compilation failed: {exc}") + else: + # ── LEGACY FLOW ────────────────────────────────────────── + if instance.clone_mode == "clone" and not instance.workspace_id: + repo = await session.get(GitRepository, instance.repository_id) + if repo and repo.ssh_key_id: + ssh_key = await session.get(SSHKey, repo.ssh_key_id) + if ssh_key: + try: + ssh_dir = prepare_ssh_key_files( + instance_dir, ssh_key, uid=0, gid=0 + ) + extra_volumes.append( + { + "source": ssh_dir, + "target": "/root/.ssh", + "type": "bind", + } + ) + logger.debug( + "Mounted SSH key for clone-mode instance %s", instance.id + ) + except Exception as exc: + logger.error( + "Failed to prepare SSH key for instance %s: %s", + instance.id, + exc, + ) + + if port_override or start_command or working_directory or extra_volumes: + modify_compose_file( + instance.compose_path, + port_override, + start_command, + working_directory, + extra_volumes, + home_dir, + ) + logger.debug("Modified compose file for instance %s", instance.id) + + # Sanitize compose file + sanitize_compose_file(instance.compose_path) + + # Auto-fix bind address for known web tools + if tool_type and tool_type.interface_type == "web": + ensure_web_bind_address( + instance.compose_path, tool_type.name, tool_type.default_port + ) + + # Ensure predictable container name + ensure_container_name_in_compose(instance.compose_path, instance.name) + ensure_backend_network_in_compose(instance.compose_path) + + # Execute docker compose up + logger.debug( + "Running docker compose up for instance %s (compose_path=%s)", + instance.id, + instance.compose_path, + ) + returncode, stdout, stderr = execute_compose_command( + instance.compose_path, "up", env_file=env_file_path + ) + logger.debug( + "Docker compose up completed for instance %s: returncode=%d, stdout=%s, stderr=%s", + instance.id, + returncode, + stdout[:200] if stdout else "", + stderr[:500] if stderr else "", + ) + + if returncode != 0: + instance.status = "error" + await session.commit() + logger.error("Failed to start instance %s: %s", instance.id, stderr) + raise RuntimeError(f"failed to start instance: {stderr}") + + # Get container ID and name + expected_container_name = instance.name.lower() + container_id = get_container_id(expected_container_name) + if container_id: + instance.container_id = container_id + logger.debug("Container ID for instance %s: %s", instance.id, container_id) + + instance.container_name = expected_container_name + logger.debug( + "Container name for instance %s: %s", instance.id, expected_container_name + ) + + # Verify container reached running state + if instance.container_id: + instance.status = "starting" + instance.last_started_at = datetime.now() + await session.commit() + await publish_lifecycle_event( + event_bus=_event_bus, + session=session, + instance=instance, + event_type="instance.started", + created_by=user_id, + status="starting", + message="Container starting...", + ) + logger.debug("Instance %s: verifying container startup...", instance.id) + + startup_result = wait_for_container_running( + instance.container_id, timeout=30, interval=2.0 + ) + + if not startup_result["success"]: + error_msg = f"Container failed to start: status={startup_result['status']}" + if startup_result["exit_code"] is not None: + error_msg += f", exit_code={startup_result['exit_code']}" + + logs = get_container_logs(instance.container_id, tail=50) + + instance.status = "error" + await session.commit() + await publish_lifecycle_event( + event_bus=_event_bus, + session=session, + instance=instance, + event_type="instance.error", + created_by=user_id, + status="error", + message=error_msg, + metadata={ + "exit_code": startup_result["exit_code"], + "error_type": "container", + }, + ) + logger.error( + "Instance %s container startup failed after %.1fs: %s\nLogs:\n%s", + instance.id, + startup_result["waited_seconds"], + error_msg, + logs, + ) + return { + "status": "error", + "error": error_msg, + "logs": logs, + } + + logger.debug( + "Instance %s container started successfully after %.1fs", + instance.id, + startup_result["waited_seconds"], + ) + + # Apply mount permission fixes for manifest-based instances + if resolved_manifest and instance.container_id: + mounts = resolved_manifest.get("mounts", []) + if mounts: + logger.debug( + "Applying permission fixes for instance %s (%d mounts)", + instance.id, + len(mounts), + ) + permission_results = apply_mount_permissions( + instance.container_id, + mounts, + ) + for result in permission_results: + if not result["success"]: + logger.warning( + "Permission fix failed for mount %s on instance %s: %s", + result["mount_name"], + instance.id, + result["error"], + ) + + # Fix SSH key ownership/permissions inside the container + if instance.ssh_key_ids and instance.container_id: + container_user = ( + "root" + if home_dir == "/root" + else home_dir[6:] + if home_dir.startswith("/home/") + else "root" + ) + ssh_target = os.path.join(home_dir, ".ssh") + logger.debug( + "Applying SSH permissions for user %s on %s in instance %s", + container_user, + ssh_target, + instance.id, + ) + ssh_perm_result = apply_ssh_permissions( + instance.container_id, + ssh_target, + container_user, + ) + if not ssh_perm_result["success"]: + logger.warning( + "SSH permission fix failed for instance %s: %s", + instance.id, + ssh_perm_result["error"], + ) + + # Execute readiness probe if configured + tool_type = await session.get(ToolType, instance.tool_type_id) + if tool_type and instance.container_id: + probe_command = None + probe_timeout = 30 + probe_interval = 2 + + if tool_type.readiness_probe: + probe_config = tool_type.readiness_probe + probe_command = probe_config.get("command", "") + probe_timeout = probe_config.get("timeout", 30) + probe_interval = probe_config.get("interval", 2) + elif tool_type.interface_type == "web": + probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}" + probe_timeout = 30 + probe_interval = 2 + + if probe_command: + instance.status = "probing" + await session.commit() + logger.debug( + "Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d", + instance.id, + probe_command, + probe_timeout, + probe_interval, + ) + + success, probe_logs = await execute_probe( + container_id=instance.container_id, + command=probe_command, + timeout=probe_timeout, + interval=probe_interval, + ) + + instance.probe_result = { + "success": success, + "command": probe_command, + "logs": probe_logs, + "timestamp": datetime.now().isoformat(), + } + + if not success: + instance.status = "unhealthy" + await session.commit() + await publish_lifecycle_event( + event_bus=_event_bus, + session=session, + instance=instance, + event_type="instance.health_changed", + created_by=user_id, + status="unhealthy", + message="Readiness probe failed", + metadata={"probe_output": "\n".join(probe_logs)}, + ) + logger.error( + "Readiness probe failed for instance %s after %ds: %s", + instance.id, + probe_timeout, + "\n".join(probe_logs), + ) + return { + "status": "unhealthy", + "error": f"Readiness probe failed after {probe_timeout}s", + "probe_logs": probe_logs, + } + + logger.info("Readiness probe succeeded for instance %s", instance.id) + + instance.status = "running" + await session.commit() + await publish_lifecycle_event( + event_bus=_event_bus, + session=session, + instance=instance, + event_type="instance.health_changed", + created_by=user_id, + status="running", + message="Container running", + metadata={"previous_status": "starting"}, + ) + logger.info("Instance %s is now running", instance.id) + + # Get tool type for default port + tool_type = await session.get(ToolType, instance.tool_type_id) + if not tool_type: + logger.error("Tool type %s not found", instance.tool_type_id) + instance.status = "error" + await session.commit() + raise RuntimeError(f"Tool type '{instance.tool_type_id}' not found") + + logger.debug( + "Tool type for instance %s: name=%s, container_port=%s, interface_type=%s", + instance.id, + tool_type.name, + tool_type.default_port or 0, + tool_type.interface_type, + ) + + # Only create Cloudflare tunnel for web-enabled tools + if tool_type.interface_type == "web": + try: + logger.debug( + "Creating tunnel for instance %s (container_port=%d)", + instance.id, + tool_type.default_port or 0, + ) + tunnel_info = start_tunnel( + instance_name=instance.name, + container_port=tool_type.default_port or 0, + ) + instance.tunnel_id = tunnel_info["container_name"] + instance.public_url = tunnel_info["url"] + instance.url = tunnel_info["url"] + await session.commit() + logger.debug( + "Created tunnel for instance %s: container=%s, url=%s", + instance.id, + tunnel_info["container_name"], + tunnel_info["url"], + ) + except Exception as exc: + import traceback + + error_msg = str(exc) + error_trace = traceback.format_exc() + logger.error( + "Failed to create tunnel for instance %s: %s\nTraceback:\n%s", + instance.id, + error_msg, + error_trace, + ) + instance.status = "error" + instance.url = None + await session.commit() + raise RuntimeError(f"Failed to create tunnel: {error_msg}") + else: + logger.info( + "Instance %s is terminal-only (no web interface), skipping tunnel creation", + instance.id, + ) + instance.url = None + instance.public_url = None + await session.commit() + + return {"status": instance.status, "url": instance.url} + + +async def restart_tool_instance( + session: AsyncSession, + user_id: uuid.UUID, + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, +) -> dict: + """Restart a tool instance. + + Returns a dict with status and url. + Raises ValueError for invalid input, RuntimeError for internal failures. + """ + instance = await session.get(ToolInstance, instance_id) + if instance is None or instance.repository_id != repo_id: + raise ValueError("instance not found") + + # Stop old tunnel if exists + if instance.tunnel_id: + try: + stop_tunnel(instance.name) + logger.debug( + "Stopped old tunnel for instance %s (container=%s)", + instance.id, + instance.tunnel_id, + ) + except Exception as exc: + logger.warning( + "Failed to stop old tunnel for instance %s: %s", instance.id, exc + ) + + # Re-apply stored config profile on restart + if instance.compose_path and os.path.exists(instance.compose_path): + instance_dir = os.path.dirname(instance.compose_path) + if instance.selected_config_profile_id is not None: + try: + resolved = await resolve_profile( + session, instance.selected_config_profile_id + ) + profile_env, profile_files, profile_mounts, profile_hints = ( + apply_resolved_profile(instance_dir, resolved) + ) + if profile_env: + write_env_file(instance_dir, profile_env) + logger.debug( + "Re-applied config profile %s on restart for instance %s", + resolved.profile_name, + instance.id, + ) + except ConfigProfileCycleError as exc: + logger.error( + "Cycle detected in stored config profile for instance %s: %s", + instance.id, + exc, + ) + + # Re-apply compose fixes + sanitize_compose_file(instance.compose_path) + tool_type = await session.get(ToolType, instance.tool_type_id) + if tool_type and tool_type.interface_type == "web": + ensure_web_bind_address( + instance.compose_path, tool_type.name, tool_type.default_port + ) + ensure_container_name_in_compose(instance.compose_path, instance.name) + ensure_backend_network_in_compose(instance.compose_path) + + returncode, stdout, stderr = execute_compose_command( + instance.compose_path, "restart" + ) + + if returncode == 0: + instance.status = "running" + instance.last_started_at = datetime.now() + + tool_type = await session.get(ToolType, instance.tool_type_id) + if not tool_type or not tool_type.default_port: + logger.error( + "Tool type %s has no default_port configured. Cannot create tunnel.", + instance.tool_type_id, + ) + instance.status = "error" + await session.commit() + raise RuntimeError( + f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured" + ) + + if tool_type.interface_type == "web": + try: + tunnel_info = start_tunnel( + instance_name=instance.name, + container_port=tool_type.default_port or 0, + ) + instance.tunnel_id = tunnel_info["container_name"] + instance.public_url = tunnel_info["url"] + instance.url = tunnel_info["url"] + logger.debug( + "Created new tunnel for instance %s: %s", + instance.id, + tunnel_info["url"], + ) + except Exception as exc: + logger.warning( + "Failed to create tunnel for instance %s: %s", + instance.id, + exc, + ) + instance.status = "error" + instance.url = None + await session.commit() + raise RuntimeError(f"Failed to create tunnel: {exc}") + else: + instance.url = None + instance.public_url = None + + await session.commit() + await publish_lifecycle_event( + event_bus=_event_bus, + session=session, + instance=instance, + event_type="instance.restarted", + created_by=user_id, + status="running", + message="Instance restarted", + ) + return {"status": instance.status, "url": instance.url} + + instance.status = "error" + await session.commit() + return {"status": instance.status} + + +async def delete_tool_instance( + session: AsyncSession, + user_id: uuid.UUID, + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, + force: bool = False, +) -> None: + """Delete a tool instance. + + Raises ValueError for invalid input. + """ + instance = await session.get(ToolInstance, instance_id) + if instance is None or instance.repository_id != repo_id: + raise ValueError("instance not found") + + # Check dirty state for clone-mode instances + if instance.clone_mode == "clone" and not force: + instance_dir = ( + os.path.dirname(instance.compose_path) if instance.compose_path else None + ) + if instance_dir: + clone_path = os.path.join(instance_dir, "repo-clone") + if os.path.exists(clone_path): + is_dirty, changed_files = check_dirty_state(clone_path) + if is_dirty: + raise RuntimeError( + f"Repository has uncommitted changes: {changed_files}" + ) + + # Stop Cloudflare tunnel if exists + if instance.tunnel_id: + try: + stop_tunnel(instance.name) + logger.debug( + "Stopped tunnel for instance %s (container=%s)", + instance.id, + instance.tunnel_id, + ) + except Exception as exc: + logger.warning( + "Failed to stop tunnel for instance %s: %s", instance.id, exc + ) + + # Stop and remove container + if instance.compose_path and os.path.exists(instance.compose_path): + execute_compose_command(instance.compose_path, "down") + + # Remove instance directory + if instance.compose_path: + instance_dir = os.path.dirname(instance.compose_path) + if os.path.exists(instance_dir): + import shutil + + shutil.rmtree(instance_dir) + + await publish_lifecycle_event( + event_bus=_event_bus, + session=session, + instance=instance, + event_type="instance.deleted", + created_by=user_id, + status="deleted", + message="Instance deleted", + ) + await session.delete(instance) + await session.commit() + + +async def recreate_instance_tunnel( + session: AsyncSession, + user_id: uuid.UUID, + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, +) -> dict: + """Recreate the temporary tunnel for an instance. + + Returns a dict with status and url. + Raises ValueError for invalid input, RuntimeError for internal failures. + """ + instance = await session.get(ToolInstance, instance_id) + if instance is None or instance.repository_id != repo_id: + raise ValueError("instance not found") + + if instance.status != "running": + raise ValueError("instance must be running to recreate tunnel") + + tool_type = await session.get(ToolType, instance.tool_type_id) + if not tool_type: + raise ValueError("Tool type not found for this instance") + + expected_name = instance.name.lower() + logger.info( + "Recreate tunnel for instance %s (expected container name: %s, default_port: %s)", + instance.id, + expected_name, + tool_type.default_port, + ) + + # Find the tool container + tool_container_id = instance.container_id + if tool_container_id: + logger.info("Using stored container_id: %s", tool_container_id) + else: + tool_container_id = get_container_id(expected_name) + if tool_container_id: + logger.info("Found container by name: %s", tool_container_id) + else: + logger.error("Container %s not found", expected_name) + raise ValueError("Could not find running container for this instance") + + # Ensure the tool container is on the backend network + network_name = get_backend_network_name() + on_network = is_container_on_network(tool_container_id, network_name) + logger.info( + "Container %s on network %s: %s", + tool_container_id, + network_name, + on_network, + ) + if not on_network: + logger.info( + "Connecting container %s to network %s", + tool_container_id, + network_name, + ) + connected = connect_container_to_network(tool_container_id, network_name) + logger.info("Network connect result: %s", connected) + + # Get the container's IP on the backend network + target_ip = get_container_ip_on_network(tool_container_id, network_name) + if target_ip: + target_url = f"http://{target_ip}:{tool_type.default_port or 0}" + logger.info( + "Tunnel target for instance %s: %s (IP %s on %s)", + instance.id, + target_url, + target_ip, + network_name, + ) + else: + target_url = f"http://{expected_name}:{tool_type.default_port or 0}" + logger.warning( + "Could not get container IP, falling back to name-based target: %s", + target_url, + ) + + try: + tunnel_info = recreate_tunnel( + instance_name=instance.name, + container_port=tool_type.default_port or 0, + target_url=target_url, + ) + logger.info( + "Tunnel recreated: container=%s, url=%s", + tunnel_info["container_name"], + tunnel_info["url"], + ) + + # Verify the tunnel can actually reach the origin + health = check_tunnel_health(tunnel_info["url"], timeout=10) + logger.info( + "Tunnel health check: status=%s, code=%s, error=%s", + health.get("tunnel_status"), + health.get("status_code"), + health.get("error"), + ) + + # Also probe from inside the API container directly to the target + probe = subprocess.run( + [ + "curl", + "-s", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "--max-time", + "5", + target_url, + ], + capture_output=True, + text=True, + ) + logger.info( + "Direct probe from API to %s: HTTP %s", target_url, probe.stdout.strip() + ) + + instance.tunnel_id = tunnel_info["container_name"] + instance.public_url = tunnel_info["url"] + instance.url = tunnel_info["url"] + await session.commit() + return {"status": "healthy", "url": instance.url} + except Exception as exc: + logger.exception("Failed to recreate tunnel for instance %s", instance.id) + raise RuntimeError(f"Failed to recreate tunnel: {str(exc)}") + + +async def stop_tool_instance( + session: AsyncSession, + user_id: uuid.UUID, + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, +) -> dict: + """Stop a tool instance. + + Returns a dict with the stopped status. + Raises ValueError for invalid input. + """ + instance = await session.get(ToolInstance, instance_id) + if instance is None or instance.repository_id != repo_id: + raise ValueError("instance not found") + + if instance.tunnel_id: + try: + stop_tunnel(instance.name) + except Exception as exc: + logger.warning("Failed to stop tunnel for instance %s: %s", instance.id, exc) + + if instance.compose_path and os.path.exists(instance.compose_path): + execute_compose_command(instance.compose_path, "stop") + + instance.status = "stopped" + instance.last_stopped_at = datetime.now() + instance.url = None + instance.public_url = None + instance.tunnel_id = None + await session.commit() + await publish_lifecycle_event( + event_bus=_event_bus, + session=session, + instance=instance, + event_type="instance.stopped", + created_by=user_id, + status="stopped", + message="Instance stopped", + ) + return {"status": instance.status}