chore: add diagnostic logging around workspace git clone
The workspace creation endpoint returns 500 but the actual error is not visible. Add explicit error logging when GitService.clone fails and info logging when git creates a directory name different from the one derived from the remote URL. Quality gates: - pytest tests/unit: 219 passed - mypy: clean on changed files
This commit is contained in:
@@ -2,12 +2,14 @@
|
||||
dir: apps/api/src/services/shared
|
||||
|
||||
## role
|
||||
Provides common infrastructure and utility services used across the API backend, including cross-cutting concerns like request tracing, file operations, notifications, Docker container management, and workspace lifecycle handling.
|
||||
Provides common infrastructure services used across the API backend, including security, I/O, container operations, and workspace lifecycle management.
|
||||
## parent
|
||||
index: apps/api/src/services/.pi-map.index.md
|
||||
map: apps/api/src/services/.pi-map.md
|
||||
## children
|
||||
-
|
||||
- apps/api/src/services/shared/.ruff_cache
|
||||
index: apps/api/src/services/shared/.ruff_cache/.pi-map.index.md
|
||||
map: apps/api/src/services/shared/.ruff_cache/.pi-map.md
|
||||
## files
|
||||
- __init__.py
|
||||
- correlation.py
|
||||
@@ -24,5 +26,7 @@ map: apps/api/src/services/shared/.pi-map.md
|
||||
## workflows
|
||||
- change shared behavior
|
||||
read: __init__.py, correlation.py, file_service.py
|
||||
- explore shared subdirectories
|
||||
index: apps/api/src/services/shared/.ruff_cache/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -4,9 +4,9 @@ dir: apps/api/src/services/shared
|
||||
index: apps/api/src/services/shared/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides common infrastructure and utility services used across the API backend, including cross-cutting concerns like request tracing, file operations, notifications, Docker container management, and workspace lifecycle handling.
|
||||
Provides common infrastructure services used across the API backend, including security, I/O, container operations, and workspace lifecycle management.
|
||||
## files
|
||||
- __init__.py | Re-exports shared service classes and functions from a services package to provide a unified public API | dep: src.services.shared.correlation, src.services.shared.file_service, src.services.shared.notification_service, src.services.shared.permission_fixer, src.services.shared.readiness_probe, src.services.shared.ssh_keys, src.services.shared.tunnel, src.services.shared.workspace_manager, correlation, file_service, notification_service, permission_fixer, readiness_probe, ssh_keys, tunnel, workspace_manager
|
||||
- __init__.py | Re-exports shared service classes and functions from a services package to provide a unified public API | dep: correlation, file_service, notification_service, permission_fixer, readiness_probe, ssh_keys, tunnel, workspace_manager
|
||||
- correlation.py | Provides async correlation ID tracking via context variables and FastAPI middleware for request tracing. | exp: class:CorrelationIdMiddleware, method:dispatch(self, request: Request, call_next), call:request.headers.get, call:str, call:uuid.uuid4, call:CORRELATION_ID.set, call:call_next, call:CORRELATION_ID.reset, func:get_correlation_id() → str, call:CORRELATION_ID.get, call:str, call:uuid.uuid4 | dep: contextvars, uuid, fastapi, starlette.middleware.base, fastapi.Request, starlette.middleware.base.BaseHTTPMiddleware
|
||||
- file_service.py | Provides safe file operations (list, read, write) scoped within a workspace directory with path traversal protection. | exp: class:FileEntry, class:FileService, method:list_directory(self, workspace: Workspace, relative_path) → list[FileEntry], call:os.path.join, call:os.path.normpath, call:abs_path.startswith, call:os.path.exists, call:sorted, call:os.listdir, call:os.path.isdir, call:os.path.getsize, call:os.path.isfile, call:entries.append, call:FileEntry, call:rel.replace, call:entries.sort, call:e.name.lower, raise:ValueError, method:read_file(self, workspace: Workspace, relative_path: str) → str, call:self._resolve_path, call:os.path.isfile, call:open, call:f.read, raise:FileNotFoundError, raise:ValueError, method:write_file(self, workspace: Workspace, relative_path: str, content: str) → None, call:self._resolve_path, call:os.makedirs, call:os.path.dirname, call:open, call:f.write, call:logger.info, method:_resolve_path(self, workspace: Workspace, relative_path: str) → str, call:os.path.normpath, call:os.path.join, call:abs_path.startswith, raise:ValueError | dep: logging, os, dataclasses, src.models, src.models.Workspace
|
||||
- notification_service.py | Provides an async singleton service for creating, listing, reading, and dismissing user-owned notifications with strict ownership isolation. | exp: class:NotificationService, method:create_notification(self, session: AsyncSession, user_id: uuid.UUID, category: str, severity: str, title: str, message, source_type, source_id, metadata) → Notification, call:Notification, call:session.add, call:session.commit, call:session.refresh, method:list_notifications(self, session: AsyncSession, user_id: uuid.UUID, limit, offset, unread_only, mute_categories) → tuple[list[Notification], int], call:Notification.dismissed_at.is_, call:where_clauses.append, call:Notification.read_at.is_, call:Notification.category.not_in, call:select(func.count()).select_from(Notification).where, call:func.count, call:session.execute, call:total_result.scalar_one, call:select(Notification) .where(*where_clauses) .order_by(Notification.created_at.desc()) .limit(limit) .offset, call:Notification.created_at.desc, call:list, call:items_result.scalars().all, method:get_unread_count(self, session: AsyncSession, user_id: uuid.UUID) → int, call:select(func.count()) .select_from(Notification) .where, call:func.count, call:Notification.read_at.is_, call:Notification.dismissed_at.is_, call:session.execute, call:result.scalar_one, method:mark_read(self, session: AsyncSession, notification_id: uuid.UUID, user_id: uuid.UUID) → Notification, call:self._get_owned_notification, call:datetime.now, call:session.commit, call:session.refresh, method:mark_all_read(self, session: AsyncSession, user_id: uuid.UUID) → int, call:update(Notification) .where( Notification.user_id == user_id, Notification.read_at.is_(None), Notification.dismissed_at.is_(None), ) .values, call:Notification.read_at.is_, call:Notification.dismissed_at.is_, call:datetime.now, call:session.execute, call:session.commit, method:dismiss_all(self, session: AsyncSession, user_id: uuid.UUID) → int, call:update(Notification) .where( Notification.user_id == user_id, Notification.dismissed_at.is_(None), ) .values, call:Notification.dismissed_at.is_, call:datetime.now, call:session.execute, call:session.commit, method:dismiss(self, session: AsyncSession, notification_id: uuid.UUID, user_id: uuid.UUID) → None, call:self._get_owned_notification, call:datetime.now, call:session.commit, method:_get_owned_notification(self, session: AsyncSession, notification_id: uuid.UUID, user_id: uuid.UUID) → Notification, call:session.get, raise:ValueError | dep: uuid, datetime, typing, sqlalchemy, sqlalchemy.engine, sqlalchemy.ext.asyncio, src.models, src.models.Notification
|
||||
@@ -14,11 +14,11 @@ Provides common infrastructure and utility services used across the API backend,
|
||||
- readiness_probe.py | Executes a retryable readiness probe command inside a Docker container with configurable timeout and interval | exp: func:execute_probe(container_id: str, command: str, timeout, interval) → tuple[bool, list[str]], call:asyncio.get_event_loop().time, call:logs.append, call:logger.debug, call:subprocess.run, call:result.stdout.strip, call:result.stderr.strip, call:asyncio.sleep | dep: asyncio, logging, subprocess
|
||||
- ssh_keys.py | Decrypts and writes SSH key files to instance directories for container mounting, with optional ownership configuration and SSH config generation. | exp: func:_get_fernet() → Fernet, call:Settings, call:hashlib.sha256(settings.session_secret.encode()).digest, call:settings.session_secret.encode, call:base64.urlsafe_b64encode, call:Fernet, func:_sanitize_filename(name: str) → str, call:re.sub, call:sanitized.strip, func:prepare_ssh_key_files(instance_dir: str, ssh_key, subdir, uid, gid, key_filename, write_config) → str, call:Path, call:ssh_dir.mkdir, call:_get_fernet, call:fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode, call:ssh_key.private_key_encrypted.encode, call:private_key_path.write_text, call:os.chmod, call:public_key_path.write_text, call:config_path.write_text, call:os.chown, call:logger.debug, call:logger.warning, call:os.getuid, call:str, func:write_ssh_config(ssh_dir: str, key_filenames: list[str], uid, gid) → None, call:Path, call:ssh_dir_path.mkdir, call:lines.append, call:"\n".join, call:config_path.write_text, call:os.chmod, call:os.chown, func:cleanup_ssh_key_files(instance_dir: str) → None, call:Path, call:ssh_dir.exists, call:ssh_dir.iterdir, call:file_path.unlink, call:ssh_dir.rmdir | dep: logging, os, re, pathlib, cryptography.fernet, src.config, base64, hashlib
|
||||
- tunnel.py | Re-exports Docker tunnel functions from a nested module for backward compatibility. | dep: src.services.docker.tunnel
|
||||
- workspace_manager.py | Manages workspace lifecycle operations including creation, deletion, synchronization, and migration of legacy tool instances into workspaces with git repository cloning. | exp: class:SyncResult, class:WorkspaceHasInstancesError, method:__init__(self, instances: list[dict]) → None, call:super().__init__, call:len, class:WorkspaceManager, method:_workspace_path(self, workspace_id: uuid.UUID, repo: "GitRepository") → str, call:os.path.join, call:str, call:self._repo_directory_name, method:create(self, repo: GitRepository, user_id: uuid.UUID, name: str, branch, session) → Workspace, call:self._repo_directory_name, call:uuid.uuid4, call:os.path.join, call:str, call:logger.info, call:os.path.exists, call:logger.warning, call:shutil.rmtree, call:os.makedirs, call:contextlib.suppress, call:os.chmod, call:getattr, call:session.execute, call:select(SSHKey).where, call:result.scalar_one_or_none, call:_get_fernet, call:fernet.decrypt( ssh_key_obj.private_key_encrypted.encode() ).decode, call:ssh_key_obj.private_key_encrypted.encode, call:GitService.clone, call:os.path.isdir, call:os.listdir, call:len, call:self._make_world_writable, call:Workspace, call:datetime.now, raise:ValueError, raise:RuntimeError, method:delete(self, workspace: Workspace, force, session) → None, call:self._get_instances, call:self._stop_and_delete_instance, call:os.path.exists, call:shutil.rmtree, call:logger.info, call:session.delete, raise:ValueError, raise:WorkspaceHasInstancesError, method:sync(self, workspace: Workspace, session) → SyncResult, call:logger.info, call:session.get, call:getattr, call:session.execute, call:select(SSHKey).where, call:result.scalar_one_or_none, call:_get_fernet, call:fernet.decrypt( ssh_key_obj.private_key_encrypted.encode() ).decode, call:ssh_key_obj.private_key_encrypted.encode, call:GitService.fetch, call:GitService.branch_exists_remotely, call:SyncResult, call:GitService.pull, call:self._make_world_writable, call:datetime.now, method:_make_world_writable(self, path: str) → None, call:contextlib.suppress, call:os.chmod, call:os.walk, call:os.path.join, call:os.stat, method:_get_instances(self, workspace: Workspace, session: AsyncSession) → list[ToolInstance], call:session.execute, call:select(ToolInstance).where, call:list, call:result.scalars().all, method:_stop_and_delete_instance(self, instance: ToolInstance, session: AsyncSession) → None, call:delete_tool_instance, call:logger.info, call:logger.error, method:ensure_instance_workspace(self, instance: ToolInstance, session: AsyncSession) → Workspace, call:session.get, call:self._workspace_name_exists, call:self._migrate_clone_into_workspace, call:self.create, call:session.add, call:session.commit, call:session.refresh, call:logger.info, raise:RuntimeError, method:_workspace_name_exists(self, session: AsyncSession, repo_id: uuid.UUID, name: str) → bool, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, method:_migrate_clone_into_workspace(self, instance: ToolInstance, repo: "GitRepository", session: AsyncSession, name: str) → Workspace, call:os.path.dirname, call:os.path.join, call:os.path.exists, call:uuid.uuid4, call:str, call:self._repo_directory_name, call:os.makedirs, call:contextlib.suppress, call:os.chmod, call:shutil.rmtree, call:shutil.move, call:self._make_world_writable, call:Workspace, call:datetime.now, call:session.add, call:session.flush, raise:RuntimeError | dep: contextlib, logging, os, shutil, stat, uuid, dataclasses, datetime, typing, sqlalchemy, src.models, src.services.git.git_service, src.services.shared.ssh_keys, src.utils.git_url_parser, sqlalchemy.ext.asyncio, src.services.tool.instance_service
|
||||
- workspace_manager.py | Manages the full lifecycle of development workspaces including creation via git clone, deletion with cleanup, remote sync, and migration of legacy tool instances into workspace-bound structures. | exp: class:SyncResult, class:WorkspaceHasInstancesError, method:__init__(self, instances: list[dict]) → None, call:super().__init__, call:len, class:WorkspaceManager, method:_workspace_path(self, workspace_id: uuid.UUID, repo: "GitRepository") → str, call:os.path.join, call:str, call:self._repo_directory_name, method:create(self, repo: GitRepository, user_id: uuid.UUID, name: str, branch, session) → Workspace, call:self._repo_directory_name, call:uuid.uuid4, call:os.path.join, call:str, call:logger.info, call:os.path.exists, call:logger.warning, call:shutil.rmtree, call:os.makedirs, call:contextlib.suppress, call:os.chmod, call:getattr, call:session.execute, call:select(SSHKey).where, call:result.scalar_one_or_none, call:_get_fernet, call:fernet.decrypt( ssh_key_obj.private_key_encrypted.encode() ).decode, call:ssh_key_obj.private_key_encrypted.encode, call:GitService.clone, call:logger.error, call:os.path.isdir, call:os.listdir, call:len, call:self._make_world_writable, call:Workspace, call:datetime.now, raise:ValueError, raise:RuntimeError, method:delete(self, workspace: Workspace, force, session) → None, call:self._get_instances, call:self._stop_and_delete_instance, call:os.path.exists, call:shutil.rmtree, call:logger.info, call:session.delete, raise:ValueError, raise:WorkspaceHasInstancesError, method:sync(self, workspace: Workspace, session) → SyncResult, call:logger.info, call:session.get, call:getattr, call:session.execute, call:select(SSHKey).where, call:result.scalar_one_or_none, call:_get_fernet, call:fernet.decrypt( ssh_key_obj.private_key_encrypted.encode() ).decode, call:ssh_key_obj.private_key_encrypted.encode, call:GitService.fetch, call:GitService.branch_exists_remotely, call:SyncResult, call:GitService.pull, call:self._make_world_writable, call:datetime.now, method:_make_world_writable(self, path: str) → None, call:contextlib.suppress, call:os.chmod, call:os.walk, call:os.path.join, call:os.stat, method:_get_instances(self, workspace: Workspace, session: AsyncSession) → list[ToolInstance], call:session.execute, call:select(ToolInstance).where, call:list, call:result.scalars().all, method:_stop_and_delete_instance(self, instance: ToolInstance, session: AsyncSession) → None, call:delete_tool_instance, call:logger.info, call:logger.error, method:ensure_instance_workspace(self, instance: ToolInstance, session: AsyncSession) → Workspace, call:session.get, call:self._workspace_name_exists, call:self._migrate_clone_into_workspace, call:self.create, call:session.add, call:session.commit, call:session.refresh, call:logger.info, raise:RuntimeError, method:_workspace_name_exists(self, session: AsyncSession, repo_id: uuid.UUID, name: str) → bool, call:session.execute, call:select(Workspace).where, call:result.scalar_one_or_none, method:_migrate_clone_into_workspace(self, instance: ToolInstance, repo: "GitRepository", session: AsyncSession, name: str) → Workspace, call:os.path.dirname, call:os.path.join, call:os.path.exists, call:uuid.uuid4, call:str, call:self._repo_directory_name, call:os.makedirs, call:contextlib.suppress, call:os.chmod, call:shutil.rmtree, call:shutil.move, call:self._make_world_writable, call:Workspace, call:datetime.now, call:session.add, call:session.flush, raise:RuntimeError | dep: contextlib, logging, os, shutil, stat, uuid, dataclasses, datetime, typing, sqlalchemy, src.models, src.services.git.git_service, src.services.shared.ssh_keys, src.utils.git_url_parser, sqlalchemy.ext.asyncio, src.services.tool.instance_service
|
||||
## arch
|
||||
Modular service-oriented architecture with singleton async services, context variable-based state propagation for request correlation, defensive path traversal protections, and Docker-centric container orchestration patterns with privilege escalation for filesystem operations.
|
||||
Modular service-oriented architecture with singleton async services, context variable-based request tracing, path traversal-safe file operations, Docker container exec-based operations, and git-based workspace provisioning with cleanup and migration support.
|
||||
## tags
|
||||
error, call:self., get, workspace, src, key, call:str, call:session.execute
|
||||
error, call:self., get, workspace, key, call:str, call:session.execute, call:ssh
|
||||
## symbols
|
||||
- CorrelationIdMiddleware
|
||||
- FileEntry
|
||||
@@ -31,5 +31,7 @@ error, call:self., get, workspace, src, key, call:str, call:session.execute
|
||||
## workflows
|
||||
- change shared behavior
|
||||
read: __init__.py, correlation.py, file_service.py
|
||||
- explore shared subdirectories
|
||||
index: apps/api/src/services/shared/.ruff_cache/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -139,7 +139,19 @@ class WorkspaceManager:
|
||||
ssh_key_obj.private_key_encrypted.encode()
|
||||
).decode()
|
||||
|
||||
await GitService.clone(repo.remote_url, branch, clone_target, ssh_key=ssh_key)
|
||||
try:
|
||||
await GitService.clone(
|
||||
repo.remote_url, branch, clone_target, ssh_key=ssh_key
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Git clone failed for workspace %s (repo=%s, url=%s): %s",
|
||||
workspace_id,
|
||||
repo.id,
|
||||
repo.remote_url,
|
||||
exc,
|
||||
)
|
||||
raise
|
||||
|
||||
expected_path = os.path.join(parent_path, repo_dir_name)
|
||||
if not os.path.isdir(expected_path):
|
||||
@@ -150,6 +162,12 @@ class WorkspaceManager:
|
||||
for entry in os.listdir(parent_path)
|
||||
if os.path.isdir(os.path.join(parent_path, entry))
|
||||
]
|
||||
logger.info(
|
||||
"Workspace %s: expected %s/ but found directories: %s",
|
||||
workspace_id,
|
||||
repo_dir_name,
|
||||
entries,
|
||||
)
|
||||
if len(entries) == 1:
|
||||
expected_path = os.path.join(parent_path, entries[0])
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user