From 9a036f19685175e28091d4dbb4e5afbe8d8ca55d Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 1 Jun 2026 19:50:07 +0200 Subject: [PATCH] fix: SSH auth for workspace sync (fetch/pull/branch check) - GitService.fetch(), pull(), branch_exists_remotely() now accept ssh_key param - WorkspaceManager.sync() loads repo SSH key from DB via session - sync_workspace endpoint passes session to manager.sync() Quality gates: ruff clean, pytest workspaces API (9 passed, 1 skipped) --- apps/api/src/api/workspaces.py | 2 +- apps/api/src/services/git_service.py | 105 ++++++++++++--------- apps/api/src/services/workspace_manager.py | 31 +++++- 3 files changed, 91 insertions(+), 47 deletions(-) diff --git a/apps/api/src/api/workspaces.py b/apps/api/src/api/workspaces.py index 2716dca..61477ed 100644 --- a/apps/api/src/api/workspaces.py +++ b/apps/api/src/api/workspaces.py @@ -346,7 +346,7 @@ async def sync_workspace( workspace = await _get_workspace(session, workspace_id, repo_id) manager = WorkspaceManager() - result = await manager.sync(workspace) + result = await manager.sync(workspace, session=session) if result.branch_deleted: raise HTTPException( diff --git a/apps/api/src/services/git_service.py b/apps/api/src/services/git_service.py index d68627b..b076ede 100644 --- a/apps/api/src/services/git_service.py +++ b/apps/api/src/services/git_service.py @@ -79,75 +79,96 @@ class GitService: os.unlink(key_path) @staticmethod - async def fetch(path: str) -> None: + async def fetch(path: str, ssh_key: str | None = None) -> None: """Fetch from origin. Args: path: The path to the local git repository. + ssh_key: Optional decrypted SSH private key for authentication. Raises: RuntimeError: If fetch fails. """ - proc = await asyncio.create_subprocess_exec( - "git", - "-C", - path, - "fetch", - "origin", - 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 fetch failed: %s", error_msg) - raise RuntimeError(f"Git fetch failed: {error_msg}") - logger.debug("Fetched origin for %s", path) + env, key_path = GitService._prepare_ssh_env(ssh_key) + try: + proc = await asyncio.create_subprocess_exec( + "git", + "-C", + path, + "fetch", + "origin", + 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 fetch failed: %s", error_msg) + raise RuntimeError(f"Git fetch failed: {error_msg}") + logger.debug("Fetched origin for %s", path) + finally: + if key_path and os.path.exists(key_path): + os.unlink(key_path) @staticmethod - async def pull(path: str, branch: str) -> None: + async def pull(path: str, branch: str, ssh_key: str | None = None) -> None: """Pull latest changes from origin. Args: path: The path to the local git repository. branch: The branch to pull. + ssh_key: Optional decrypted SSH private key for authentication. Raises: RuntimeError: If pull fails. """ - proc = await asyncio.create_subprocess_exec( - "git", - "-C", - path, - "pull", - "origin", - branch, - 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 pull failed: %s", error_msg) - raise RuntimeError(f"Git pull failed: {error_msg}") - logger.debug("Pulled origin/%s for %s", branch, path) + env, key_path = GitService._prepare_ssh_env(ssh_key) + try: + proc = await asyncio.create_subprocess_exec( + "git", + "-C", + path, + "pull", + "origin", + branch, + 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 pull failed: %s", error_msg) + raise RuntimeError(f"Git pull failed: {error_msg}") + logger.debug("Pulled origin/%s for %s", branch, path) + finally: + if key_path and os.path.exists(key_path): + os.unlink(key_path) @staticmethod - def branch_exists_remotely(path: str, branch: str) -> bool: + def branch_exists_remotely(path: str, branch: str, ssh_key: str | None = None) -> bool: """Check if a branch exists on the remote. Args: path: The path to the local git repository. branch: The branch name to check. + ssh_key: Optional decrypted SSH private key for authentication. Returns: True if the branch exists on origin, False otherwise. """ - result = subprocess.run( - ["git", "-C", path, "ls-remote", "--heads", "origin", branch], - capture_output=True, - text=True, - ) - exists = result.returncode == 0 and result.stdout.strip() != "" - logger.debug("Branch %s exists on remote: %s", branch, exists) - return exists + env, key_path = GitService._prepare_ssh_env(ssh_key) + try: + result = subprocess.run( + ["git", "-C", path, "ls-remote", "--heads", "origin", branch], + capture_output=True, + text=True, + env={**os.environ, **env} if env else None, + ) + exists = result.returncode == 0 and result.stdout.strip() != "" + logger.debug("Branch %s exists on remote: %s", branch, exists) + return exists + finally: + if key_path and os.path.exists(key_path): + os.unlink(key_path) diff --git a/apps/api/src/services/workspace_manager.py b/apps/api/src/services/workspace_manager.py index f24d0f4..a37c703 100644 --- a/apps/api/src/services/workspace_manager.py +++ b/apps/api/src/services/workspace_manager.py @@ -147,11 +147,14 @@ class WorkspaceManager: await session.delete(workspace) logger.info("Deleted workspace record: %s", workspace.id) - async def sync(self, workspace: Workspace) -> SyncResult: + async def sync( + self, workspace: Workspace, session: AsyncSession | None = None + ) -> SyncResult: """Sync a workspace with its remote. Args: workspace: The workspace to sync. + session: Database session for loading SSH keys. Returns: SyncResult indicating whether the branch was deleted. @@ -161,12 +164,32 @@ class WorkspaceManager: """ logger.info("Syncing workspace: %s", workspace.id) - await GitService.fetch(workspace.path) + # Load SSH key if repo has one + ssh_key = None + if session is not None: + from src.models.git_repository import GitRepository + from src.models.ssh_key import SSHKey - if not GitService.branch_exists_remotely(workspace.path, workspace.branch): + repo = await session.get(GitRepository, workspace.repo_id) + if repo and getattr(repo, "ssh_key_id", None): + 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.fetch(workspace.path, ssh_key=ssh_key) + + if not GitService.branch_exists_remotely( + workspace.path, workspace.branch, ssh_key=ssh_key + ): return SyncResult(branch_deleted=True) - await GitService.pull(workspace.path, workspace.branch) + await GitService.pull(workspace.path, workspace.branch, ssh_key=ssh_key) workspace.last_sync_at = datetime.now() logger.info("Workspace synced: %s", workspace.id) return SyncResult(branch_deleted=False)