fix: workspace creation SSH authentication
- GitService.clone() now accepts ssh_key and sets up GIT_SSH_COMMAND env - WorkspaceManager.create() loads repo SSH key from DB and decrypts it - Both workspace create endpoints pass session for SSH key lookup Quality gates: ruff clean, pytest workspaces API (9 passed, 1 skipped)
This commit is contained in:
@@ -95,7 +95,7 @@ async def create_workspace_top_level(
|
|||||||
|
|
||||||
manager = WorkspaceManager()
|
manager = WorkspaceManager()
|
||||||
try:
|
try:
|
||||||
workspace = await manager.create(repo, user_id, name, branch)
|
workspace = await manager.create(repo, user_id, name, branch, session=session)
|
||||||
session.add(workspace)
|
session.add(workspace)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -191,7 +191,7 @@ async def create_workspace(
|
|||||||
|
|
||||||
manager = WorkspaceManager()
|
manager = WorkspaceManager()
|
||||||
try:
|
try:
|
||||||
workspace = await manager.create(repo, user_id, name, branch)
|
workspace = await manager.create(repo, user_id, name, branch, session=session)
|
||||||
session.add(workspace)
|
session.add(workspace)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -11,13 +13,39 @@ class GitService:
|
|||||||
"""Low-level git operations for creating and syncing workspaces."""
|
"""Low-level git operations for creating and syncing workspaces."""
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
"""Clone a repository to the given path.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
remote_url: The git remote URL.
|
remote_url: The git remote URL.
|
||||||
branch: The branch to clone.
|
branch: The branch to clone.
|
||||||
path: The destination path for the clone.
|
path: The destination path for the clone.
|
||||||
|
ssh_key: Optional decrypted SSH private key for authentication.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If the clone fails.
|
RuntimeError: If the clone fails.
|
||||||
@@ -31,17 +59,24 @@ class GitService:
|
|||||||
remote_url,
|
remote_url,
|
||||||
path,
|
path,
|
||||||
]
|
]
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
*cmd,
|
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||||
stdout=asyncio.subprocess.PIPE,
|
try:
|
||||||
stderr=asyncio.subprocess.PIPE,
|
proc = await asyncio.create_subprocess_exec(
|
||||||
)
|
*cmd,
|
||||||
stdout, stderr = await proc.communicate()
|
stdout=asyncio.subprocess.PIPE,
|
||||||
if proc.returncode != 0:
|
stderr=asyncio.subprocess.PIPE,
|
||||||
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
env={**os.environ, **env} if env else None,
|
||||||
logger.error("Git clone failed: %s", error_msg)
|
)
|
||||||
raise RuntimeError(f"Git clone failed: {error_msg}")
|
stdout, stderr = await proc.communicate()
|
||||||
logger.debug("Cloned %s (branch: %s) to %s", remote_url, branch, path)
|
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
|
@staticmethod
|
||||||
async def fetch(path: str) -> None:
|
async def fetch(path: str) -> None:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from src.models.workspace import Workspace
|
from src.models.workspace import Workspace
|
||||||
from src.services.git_service import GitService
|
from src.services.git_service import GitService
|
||||||
|
from src.services.ssh_keys import _get_fernet
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -54,6 +55,7 @@ class WorkspaceManager:
|
|||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
name: str,
|
name: str,
|
||||||
branch: str = "main",
|
branch: str = "main",
|
||||||
|
session: AsyncSession | None = None,
|
||||||
) -> Workspace:
|
) -> Workspace:
|
||||||
"""Clone repo to workspace path and create DB record.
|
"""Clone repo to workspace path and create DB record.
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ class WorkspaceManager:
|
|||||||
user_id: The owner user ID.
|
user_id: The owner user ID.
|
||||||
name: The workspace name (unique per repo).
|
name: The workspace name (unique per repo).
|
||||||
branch: The branch to clone (default: "main").
|
branch: The branch to clone (default: "main").
|
||||||
|
session: Database session for loading SSH keys.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The created Workspace record.
|
The created Workspace record.
|
||||||
@@ -79,7 +82,22 @@ class WorkspaceManager:
|
|||||||
if not repo.remote_url:
|
if not repo.remote_url:
|
||||||
raise ValueError("Repository has no 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(
|
workspace = Workspace(
|
||||||
name=name,
|
name=name,
|
||||||
|
|||||||
Reference in New Issue
Block a user