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)
This commit is contained in:
@@ -346,7 +346,7 @@ async def sync_workspace(
|
|||||||
workspace = await _get_workspace(session, workspace_id, repo_id)
|
workspace = await _get_workspace(session, workspace_id, repo_id)
|
||||||
|
|
||||||
manager = WorkspaceManager()
|
manager = WorkspaceManager()
|
||||||
result = await manager.sync(workspace)
|
result = await manager.sync(workspace, session=session)
|
||||||
|
|
||||||
if result.branch_deleted:
|
if result.branch_deleted:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -79,75 +79,96 @@ class GitService:
|
|||||||
os.unlink(key_path)
|
os.unlink(key_path)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def fetch(path: str) -> None:
|
async def fetch(path: str, ssh_key: str | None = None) -> None:
|
||||||
"""Fetch from origin.
|
"""Fetch from origin.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: The path to the local git repository.
|
path: The path to the local git repository.
|
||||||
|
ssh_key: Optional decrypted SSH private key for authentication.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If fetch fails.
|
RuntimeError: If fetch fails.
|
||||||
"""
|
"""
|
||||||
proc = await asyncio.create_subprocess_exec(
|
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||||
"git",
|
try:
|
||||||
"-C",
|
proc = await asyncio.create_subprocess_exec(
|
||||||
path,
|
"git",
|
||||||
"fetch",
|
"-C",
|
||||||
"origin",
|
path,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
"fetch",
|
||||||
stderr=asyncio.subprocess.PIPE,
|
"origin",
|
||||||
)
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stdout, stderr = await proc.communicate()
|
stderr=asyncio.subprocess.PIPE,
|
||||||
if proc.returncode != 0:
|
env={**os.environ, **env} if env else None,
|
||||||
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
)
|
||||||
logger.error("Git fetch failed: %s", error_msg)
|
stdout, stderr = await proc.communicate()
|
||||||
raise RuntimeError(f"Git fetch failed: {error_msg}")
|
if proc.returncode != 0:
|
||||||
logger.debug("Fetched origin for %s", path)
|
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
|
@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.
|
"""Pull latest changes from origin.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: The path to the local git repository.
|
path: The path to the local git repository.
|
||||||
branch: The branch to pull.
|
branch: The branch to pull.
|
||||||
|
ssh_key: Optional decrypted SSH private key for authentication.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If pull fails.
|
RuntimeError: If pull fails.
|
||||||
"""
|
"""
|
||||||
proc = await asyncio.create_subprocess_exec(
|
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||||
"git",
|
try:
|
||||||
"-C",
|
proc = await asyncio.create_subprocess_exec(
|
||||||
path,
|
"git",
|
||||||
"pull",
|
"-C",
|
||||||
"origin",
|
path,
|
||||||
branch,
|
"pull",
|
||||||
stdout=asyncio.subprocess.PIPE,
|
"origin",
|
||||||
stderr=asyncio.subprocess.PIPE,
|
branch,
|
||||||
)
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stdout, stderr = await proc.communicate()
|
stderr=asyncio.subprocess.PIPE,
|
||||||
if proc.returncode != 0:
|
env={**os.environ, **env} if env else None,
|
||||||
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
)
|
||||||
logger.error("Git pull failed: %s", error_msg)
|
stdout, stderr = await proc.communicate()
|
||||||
raise RuntimeError(f"Git pull failed: {error_msg}")
|
if proc.returncode != 0:
|
||||||
logger.debug("Pulled origin/%s for %s", branch, path)
|
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
|
@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.
|
"""Check if a branch exists on the remote.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: The path to the local git repository.
|
path: The path to the local git repository.
|
||||||
branch: The branch name to check.
|
branch: The branch name to check.
|
||||||
|
ssh_key: Optional decrypted SSH private key for authentication.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if the branch exists on origin, False otherwise.
|
True if the branch exists on origin, False otherwise.
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||||
["git", "-C", path, "ls-remote", "--heads", "origin", branch],
|
try:
|
||||||
capture_output=True,
|
result = subprocess.run(
|
||||||
text=True,
|
["git", "-C", path, "ls-remote", "--heads", "origin", branch],
|
||||||
)
|
capture_output=True,
|
||||||
exists = result.returncode == 0 and result.stdout.strip() != ""
|
text=True,
|
||||||
logger.debug("Branch %s exists on remote: %s", branch, exists)
|
env={**os.environ, **env} if env else None,
|
||||||
return exists
|
)
|
||||||
|
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)
|
||||||
|
|||||||
@@ -147,11 +147,14 @@ class WorkspaceManager:
|
|||||||
await session.delete(workspace)
|
await session.delete(workspace)
|
||||||
logger.info("Deleted workspace record: %s", workspace.id)
|
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.
|
"""Sync a workspace with its remote.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
workspace: The workspace to sync.
|
workspace: The workspace to sync.
|
||||||
|
session: Database session for loading SSH keys.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
SyncResult indicating whether the branch was deleted.
|
SyncResult indicating whether the branch was deleted.
|
||||||
@@ -161,12 +164,32 @@ class WorkspaceManager:
|
|||||||
"""
|
"""
|
||||||
logger.info("Syncing workspace: %s", workspace.id)
|
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)
|
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()
|
workspace.last_sync_at = datetime.now()
|
||||||
logger.info("Workspace synced: %s", workspace.id)
|
logger.info("Workspace synced: %s", workspace.id)
|
||||||
return SyncResult(branch_deleted=False)
|
return SyncResult(branch_deleted=False)
|
||||||
|
|||||||
Reference in New Issue
Block a user