diff --git a/apps/api/src/api/workspaces.py b/apps/api/src/api/workspaces.py index 0f8f4ad..2716dca 100644 --- a/apps/api/src/api/workspaces.py +++ b/apps/api/src/api/workspaces.py @@ -95,7 +95,7 @@ async def create_workspace_top_level( manager = WorkspaceManager() try: - workspace = await manager.create(repo, user_id, name, branch) + workspace = await manager.create(repo, user_id, name, branch, session=session) session.add(workspace) await session.commit() except Exception as exc: @@ -191,7 +191,7 @@ async def create_workspace( manager = WorkspaceManager() try: - workspace = await manager.create(repo, user_id, name, branch) + workspace = await manager.create(repo, user_id, name, branch, session=session) session.add(workspace) await session.commit() except Exception as exc: diff --git a/apps/api/src/services/git_service.py b/apps/api/src/services/git_service.py index 082370a..d68627b 100644 --- a/apps/api/src/services/git_service.py +++ b/apps/api/src/services/git_service.py @@ -2,7 +2,9 @@ import asyncio import logging +import os import subprocess +import tempfile logger = logging.getLogger(__name__) @@ -11,13 +13,39 @@ class GitService: """Low-level git operations for creating and syncing workspaces.""" @staticmethod - async def clone(remote_url: str, branch: str, path: str) -> None: + def _prepare_ssh_env( + ssh_key: str | None, + ) -> tuple[dict[str, str] | None, str | None]: + """Prepare environment for git commands with SSH authentication. + + Returns a tuple of (env_dict, temp_key_path). Caller must clean up key_path. + """ + if not ssh_key: + return None, None + + fd, key_path = tempfile.mkstemp(prefix="ssh_key_") + try: + os.write(fd, ssh_key.encode()) + finally: + os.close(fd) + os.chmod(key_path, 0o600) + + env = { + "GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" + } + return env, key_path + + @staticmethod + async def clone( + remote_url: str, branch: str, path: str, ssh_key: str | None = None + ) -> None: """Clone a repository to the given path. Args: remote_url: The git remote URL. branch: The branch to clone. path: The destination path for the clone. + ssh_key: Optional decrypted SSH private key for authentication. Raises: RuntimeError: If the clone fails. @@ -31,17 +59,24 @@ class GitService: remote_url, path, ] - proc = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await proc.communicate() - if proc.returncode != 0: - error_msg = stderr.decode().strip() if stderr else "unknown error" - logger.error("Git clone failed: %s", error_msg) - raise RuntimeError(f"Git clone failed: {error_msg}") - logger.debug("Cloned %s (branch: %s) to %s", remote_url, branch, path) + + env, key_path = GitService._prepare_ssh_env(ssh_key) + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={**os.environ, **env} if env else None, + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + error_msg = stderr.decode().strip() if stderr else "unknown error" + logger.error("Git clone failed: %s", error_msg) + raise RuntimeError(f"Git clone failed: {error_msg}") + logger.debug("Cloned %s (branch: %s) to %s", remote_url, branch, path) + finally: + if key_path and os.path.exists(key_path): + os.unlink(key_path) @staticmethod async def fetch(path: str) -> None: diff --git a/apps/api/src/services/workspace_manager.py b/apps/api/src/services/workspace_manager.py index cc7d886..f24d0f4 100644 --- a/apps/api/src/services/workspace_manager.py +++ b/apps/api/src/services/workspace_manager.py @@ -14,6 +14,7 @@ from sqlalchemy import select from src.models.workspace import Workspace from src.services.git_service import GitService +from src.services.ssh_keys import _get_fernet if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession @@ -54,6 +55,7 @@ class WorkspaceManager: user_id: uuid.UUID, name: str, branch: str = "main", + session: AsyncSession | None = None, ) -> Workspace: """Clone repo to workspace path and create DB record. @@ -62,6 +64,7 @@ class WorkspaceManager: user_id: The owner user ID. name: The workspace name (unique per repo). branch: The branch to clone (default: "main"). + session: Database session for loading SSH keys. Returns: The created Workspace record. @@ -79,7 +82,22 @@ class WorkspaceManager: if not repo.remote_url: raise ValueError("Repository has no remote URL") - await GitService.clone(repo.remote_url, branch, path) + # Load SSH key if repo has one + ssh_key = None + if getattr(repo, "ssh_key_id", None) and session is not None: + from src.models.ssh_key import SSHKey + + result = await session.execute( + select(SSHKey).where(SSHKey.id == repo.ssh_key_id) + ) + ssh_key_obj = result.scalar_one_or_none() + if ssh_key_obj: + fernet = _get_fernet() + ssh_key = fernet.decrypt( + ssh_key_obj.private_key_encrypted.encode() + ).decode() + + await GitService.clone(repo.remote_url, branch, path, ssh_key=ssh_key) workspace = Workspace( name=name,