refactor: organize API routers and services into subpackages
Service organization (19 files moved into 6 subpackages): - services/instance/ — event_bus, health_monitor, lifecycle_hooks - services/config/ — config_profile_resolver - services/git/ — clone, git_operations, git_service - services/build/ — docker_build, manifest_compiler - services/terminal/ — terminal_manager, terminal_session - services/shared/ — correlation, file_service, notification_service, permission_fixer, readiness_probe, ssh_keys, tunnel, workspace_manager API router organization (16 files moved into 6 subpackages): - api/tool/ — tool_instances, tool_types, tool_definitions, tool_types_validation, sessions (extracted from tool_instances) - api/config/ — config_profiles, user_config - api/workspace/ — workspaces, workspace_files, workspace_git, workspace_instances - api/user/ — users, auth, ssh_keys - api/project/ — projects, git_repositories - api/system/ — health, events, notifications, dashboard, terminal, instance_proxy Updated main.py imports and all __init__.py re-exports. Sessions router extracted from tool_instances.py into api/tool/sessions.py. Quality gates: py_compile passed, ruff passed.
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
"""Workspace lifecycle management service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.models import Workspace
|
||||
from src.services.git.git_service import GitService
|
||||
from src.services.shared.ssh_keys import _get_fernet
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models import GitRepository
|
||||
from src.models import ToolInstance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncResult:
|
||||
"""Result of a workspace sync operation."""
|
||||
|
||||
branch_deleted: bool = False
|
||||
|
||||
|
||||
class WorkspaceHasInstancesError(Exception):
|
||||
"""Raised when attempting to delete a workspace with running instances."""
|
||||
|
||||
def __init__(self, instances: list[dict]) -> None:
|
||||
self.instances = instances
|
||||
super().__init__(f"Workspace has {len(instances)} running tool instance(s)")
|
||||
|
||||
|
||||
class WorkspaceManager:
|
||||
"""Manages workspace lifecycle: create, delete, sync, validate."""
|
||||
|
||||
BASE_PATH = "/data/working-copies"
|
||||
|
||||
def _workspace_path(self, repo_id: uuid.UUID, name: str) -> str:
|
||||
"""Return the filesystem path for a workspace."""
|
||||
return os.path.join(self.BASE_PATH, str(repo_id), name)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
repo: GitRepository,
|
||||
user_id: uuid.UUID,
|
||||
name: str,
|
||||
branch: str = "main",
|
||||
session: AsyncSession | None = None,
|
||||
) -> Workspace:
|
||||
"""Clone repo to workspace path and create DB record.
|
||||
|
||||
Args:
|
||||
repo: The git repository to clone.
|
||||
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.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If git clone fails.
|
||||
"""
|
||||
path = self._workspace_path(repo.id, name)
|
||||
parent = os.path.dirname(path)
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
# Ensure container users (various UIDs) can write to workspace dirs
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(parent, 0o777)
|
||||
|
||||
logger.info(
|
||||
"Creating workspace: name=%s, repo=%s, branch=%s", name, repo.id, branch
|
||||
)
|
||||
|
||||
if not repo.remote_url:
|
||||
raise ValueError("Repository has no remote URL")
|
||||
|
||||
# Remove stale directory from previous failed/aborted clone
|
||||
if os.path.exists(path):
|
||||
logger.warning("Removing stale workspace directory: %s", path)
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
# 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 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)
|
||||
self._make_world_writable(path)
|
||||
|
||||
workspace = Workspace(
|
||||
name=name,
|
||||
repo_id=repo.id,
|
||||
user_id=user_id,
|
||||
branch=branch,
|
||||
path=path,
|
||||
status="ready",
|
||||
last_sync_at=datetime.now(),
|
||||
)
|
||||
logger.info("Workspace created: %s", workspace.id)
|
||||
return workspace
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
force: bool = False,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Delete a workspace and all associated tool instances.
|
||||
|
||||
Args:
|
||||
workspace: The workspace to delete.
|
||||
force: If True, delete even if instances exist.
|
||||
session: The database session (required for checking instances).
|
||||
|
||||
Raises:
|
||||
WorkspaceHasInstancesError: If instances exist and force=False.
|
||||
"""
|
||||
if session is None:
|
||||
raise ValueError("session is required for delete")
|
||||
|
||||
instances = await self._get_instances(workspace, session)
|
||||
if instances and not force:
|
||||
raise WorkspaceHasInstancesError(
|
||||
[{"id": str(i.id), "name": i.name} for i in instances]
|
||||
)
|
||||
|
||||
# Stop and delete all instances
|
||||
for instance in instances:
|
||||
await self._stop_and_delete_instance(instance)
|
||||
|
||||
# Delete directory
|
||||
if os.path.exists(workspace.path):
|
||||
shutil.rmtree(workspace.path, ignore_errors=True)
|
||||
logger.info("Deleted workspace directory: %s", workspace.path)
|
||||
|
||||
# Delete record
|
||||
await session.delete(workspace)
|
||||
logger.info("Deleted workspace record: %s", workspace.id)
|
||||
|
||||
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.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If git operations fail.
|
||||
"""
|
||||
logger.info("Syncing workspace: %s", workspace.id)
|
||||
|
||||
# Load SSH key if repo has one
|
||||
ssh_key = None
|
||||
if session is not None:
|
||||
from src.models import GitRepository
|
||||
from src.models import SSHKey
|
||||
|
||||
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, ssh_key=ssh_key)
|
||||
self._make_world_writable(workspace.path)
|
||||
|
||||
workspace.last_sync_at = datetime.now()
|
||||
logger.info("Workspace synced: %s", workspace.id)
|
||||
return SyncResult(branch_deleted=False)
|
||||
|
||||
def _make_world_writable(self, path: str) -> None:
|
||||
"""Recursively make path readable/writable/traversable by any UID.
|
||||
|
||||
Directories get 777 (traversable). Files get rw for all while
|
||||
preserving any existing execute bits.
|
||||
"""
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(path, 0o777)
|
||||
for root, dirs, files in os.walk(path):
|
||||
for d in dirs:
|
||||
dpath = os.path.join(root, d)
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(dpath, 0o777)
|
||||
for f in files:
|
||||
fpath = os.path.join(root, f)
|
||||
with contextlib.suppress(OSError):
|
||||
mode = os.stat(fpath).st_mode
|
||||
# Preserve execute bits, ensure read+write for all
|
||||
new_mode = (mode & stat.S_IXUSR) | 0o666
|
||||
if mode & stat.S_IXGRP:
|
||||
new_mode |= stat.S_IXGRP
|
||||
if mode & stat.S_IXOTH:
|
||||
new_mode |= stat.S_IXOTH
|
||||
os.chmod(fpath, new_mode)
|
||||
|
||||
async def _get_instances(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
session: AsyncSession,
|
||||
) -> list[ToolInstance]:
|
||||
"""Get all tool instances associated with this workspace."""
|
||||
from src.models import ToolInstance
|
||||
|
||||
result = await session.execute(
|
||||
select(ToolInstance).where(ToolInstance.workspace_id == workspace.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def _stop_and_delete_instance(self, instance: ToolInstance) -> None:
|
||||
"""Stop and delete a tool instance.
|
||||
|
||||
TODO(PR-2): Wire up to actual instance stop/delete logic.
|
||||
For now, this is a placeholder.
|
||||
"""
|
||||
logger.warning("Placeholder: stopping and deleting instance %s", instance.id)
|
||||
Reference in New Issue
Block a user