WIP: working-copies backend cleanup
- Remove clone_mode/branch from API responses and make DB columns nullable - Remove legacy clone-mode branches from create_tool_instance - Add WORKSPACE_PATH compose variable alongside REPO_PATH - Add workspace migration helpers in WorkspaceManager Remaining: POST /workspaces/:id/instances, frontend clone_mode cleanup, tests
This commit is contained in:
@@ -74,8 +74,6 @@ async def get_user_sessions(
|
|||||||
"workspace_name": workspace_name,
|
"workspace_name": workspace_name,
|
||||||
"status": instance.status,
|
"status": instance.status,
|
||||||
"url": instance.url,
|
"url": instance.url,
|
||||||
"clone_mode": instance.clone_mode,
|
|
||||||
"branch": instance.branch,
|
|
||||||
"selected_config_profile_id": str(instance.selected_config_profile_id)
|
"selected_config_profile_id": str(instance.selected_config_profile_id)
|
||||||
if instance.selected_config_profile_id
|
if instance.selected_config_profile_id
|
||||||
else None,
|
else None,
|
||||||
|
|||||||
@@ -68,8 +68,7 @@ async def create_instance(
|
|||||||
"display_name": instance.display_name,
|
"display_name": instance.display_name,
|
||||||
"tool_type_id": str(instance.tool_type_id),
|
"tool_type_id": str(instance.tool_type_id),
|
||||||
"status": instance.status,
|
"status": instance.status,
|
||||||
"clone_mode": instance.clone_mode,
|
"workspace_id": str(instance.workspace_id) if instance.workspace_id else None,
|
||||||
"branch": instance.branch,
|
|
||||||
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
|
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
|
||||||
"created_at": instance.created_at.isoformat(),
|
"created_at": instance.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
@@ -143,8 +142,6 @@ async def get_instance(
|
|||||||
"container_id": instance.container_id,
|
"container_id": instance.container_id,
|
||||||
"container_name": instance.container_name,
|
"container_name": instance.container_name,
|
||||||
"compose_path": instance.compose_path,
|
"compose_path": instance.compose_path,
|
||||||
"clone_mode": instance.clone_mode,
|
|
||||||
"branch": instance.branch,
|
|
||||||
"workspace_id": str(instance.workspace_id) if instance.workspace_id else None,
|
"workspace_id": str(instance.workspace_id) if instance.workspace_id else None,
|
||||||
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
|
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
|
||||||
"ssh_key_ids": instance.ssh_key_ids,
|
"ssh_key_ids": instance.ssh_key_ids,
|
||||||
|
|||||||
@@ -53,9 +53,11 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
)
|
)
|
||||||
image_tag: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
image_tag: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
probe_result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
probe_result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
clone_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="mount")
|
clone_mode: Mapped[str | None] = mapped_column(
|
||||||
|
String(20), nullable=True, default=None
|
||||||
|
)
|
||||||
branch: Mapped[str | None] = mapped_column(
|
branch: Mapped[str | None] = mapped_column(
|
||||||
String(255), nullable=True, default="main"
|
String(255), nullable=True, default=None
|
||||||
)
|
)
|
||||||
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||||
|
|||||||
@@ -13,16 +13,24 @@ class CreateInstanceRequest(BaseModel):
|
|||||||
default=None, description="Optional display name for the instance"
|
default=None, description="Optional display name for the instance"
|
||||||
)
|
)
|
||||||
workspace_id: str | None = Field(
|
workspace_id: str | None = Field(
|
||||||
default=None, description="UUID of workspace to mount (replaces clone_mode)"
|
default=None, description="UUID of workspace to mount"
|
||||||
)
|
)
|
||||||
clone_mode: str = Field(
|
config_profile_id: str | None = Field(
|
||||||
default="mount", description="Repository access mode: 'mount' or 'clone'"
|
default=None, description="Optional config profile ID for launch"
|
||||||
)
|
)
|
||||||
branch: str | None = Field(
|
ssh_key_ids: list[str] = Field(
|
||||||
default="main", description="Branch to clone (when clone_mode='clone')"
|
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
|
||||||
)
|
)
|
||||||
new_branch: str | None = Field(
|
|
||||||
default=None, description="Create a new local branch after cloning"
|
|
||||||
|
class CreateWorkspaceInstanceRequest(BaseModel):
|
||||||
|
"""Request body for creating a tool instance directly on a workspace."""
|
||||||
|
|
||||||
|
model_config = {"extra": "ignore"}
|
||||||
|
|
||||||
|
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
|
||||||
|
display_name: str | None = Field(
|
||||||
|
default=None, description="Optional display name for the instance"
|
||||||
)
|
)
|
||||||
config_profile_id: str | None = Field(
|
config_profile_id: str | None = Field(
|
||||||
default=None, description="Optional config profile ID for launch"
|
default=None, description="Optional config profile ID for launch"
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ class WorkspaceManager:
|
|||||||
|
|
||||||
# Stop and delete all instances
|
# Stop and delete all instances
|
||||||
for instance in instances:
|
for instance in instances:
|
||||||
await self._stop_and_delete_instance(instance)
|
await self._stop_and_delete_instance(instance, session)
|
||||||
|
|
||||||
# Delete directory
|
# Delete directory
|
||||||
if os.path.exists(workspace.path):
|
if os.path.exists(workspace.path):
|
||||||
@@ -248,10 +248,154 @@ class WorkspaceManager:
|
|||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
async def _stop_and_delete_instance(self, instance: ToolInstance) -> None:
|
async def _stop_and_delete_instance(
|
||||||
|
self,
|
||||||
|
instance: ToolInstance,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
"""Stop and delete a tool instance.
|
"""Stop and delete a tool instance.
|
||||||
|
|
||||||
TODO(PR-2): Wire up to actual instance stop/delete logic.
|
Delegates to the instance lifecycle service to ensure containers,
|
||||||
For now, this is a placeholder.
|
tunnels, and on-disk files are cleaned up.
|
||||||
"""
|
"""
|
||||||
logger.warning("Placeholder: stopping and deleting instance %s", instance.id)
|
# Local import avoids a circular dependency between workspace and
|
||||||
|
# instance lifecycle modules.
|
||||||
|
from src.services.tool.instance_service import delete_tool_instance
|
||||||
|
|
||||||
|
try:
|
||||||
|
await delete_tool_instance(
|
||||||
|
session=session,
|
||||||
|
user_id=instance.owner_id,
|
||||||
|
project_id=instance.project_id,
|
||||||
|
repo_id=instance.repository_id,
|
||||||
|
instance_id=instance.id,
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
logger.info("Stopped and deleted instance %s", instance.id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to stop/delete instance %s during workspace cleanup: %s",
|
||||||
|
instance.id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def ensure_instance_workspace(
|
||||||
|
self,
|
||||||
|
instance: ToolInstance,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> Workspace:
|
||||||
|
"""Return the workspace for an instance, creating/binding one if needed.
|
||||||
|
|
||||||
|
This migrates legacy instances that were created before workspaces
|
||||||
|
existed. Clone-mode instances have their existing clone moved into a
|
||||||
|
workspace, while mount-mode instances get a fresh workspace from the
|
||||||
|
canonical repository.
|
||||||
|
|
||||||
|
The operation is best-effort: failures are re-raised so the caller can
|
||||||
|
decide whether to continue with a legacy fallback path.
|
||||||
|
"""
|
||||||
|
if instance.workspace_id is not None:
|
||||||
|
workspace = await session.get(Workspace, instance.workspace_id)
|
||||||
|
if workspace is not None:
|
||||||
|
return workspace
|
||||||
|
|
||||||
|
from src.models import GitRepository
|
||||||
|
|
||||||
|
repo = await session.get(GitRepository, instance.repository_id)
|
||||||
|
if repo is None:
|
||||||
|
raise RuntimeError(f"Repository {instance.repository_id} not found")
|
||||||
|
|
||||||
|
base_name = (
|
||||||
|
f"{instance.name}-migrated"
|
||||||
|
if instance.clone_mode == "clone"
|
||||||
|
else f"{instance.name}-legacy"
|
||||||
|
)
|
||||||
|
name = base_name
|
||||||
|
counter = 1
|
||||||
|
while await self._workspace_name_exists(session, repo.id, name):
|
||||||
|
name = f"{base_name}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
if instance.clone_mode == "clone":
|
||||||
|
workspace = await self._migrate_clone_into_workspace(
|
||||||
|
instance, repo, session, name
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
workspace = await self.create(
|
||||||
|
repo=repo,
|
||||||
|
user_id=instance.owner_id,
|
||||||
|
name=name,
|
||||||
|
branch=instance.branch or "main",
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
instance.workspace_id = workspace.id
|
||||||
|
instance.clone_mode = None
|
||||||
|
session.add(instance)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(instance)
|
||||||
|
logger.info(
|
||||||
|
"Migrated instance %s to workspace %s (%s)",
|
||||||
|
instance.id,
|
||||||
|
workspace.id,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
return workspace
|
||||||
|
|
||||||
|
async def _workspace_name_exists(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
name: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Check whether a workspace name already exists for a repository."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(Workspace).where(
|
||||||
|
Workspace.repo_id == repo_id,
|
||||||
|
Workspace.name == name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
async def _migrate_clone_into_workspace(
|
||||||
|
self,
|
||||||
|
instance: ToolInstance,
|
||||||
|
repo: "GitRepository",
|
||||||
|
session: AsyncSession,
|
||||||
|
name: str,
|
||||||
|
) -> Workspace:
|
||||||
|
"""Move an existing clone-mode repo into a new workspace path."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
if not instance.compose_path:
|
||||||
|
raise RuntimeError("Instance has no compose path")
|
||||||
|
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
|
clone_path = os.path.join(instance_dir, "repo-clone")
|
||||||
|
if not os.path.exists(clone_path):
|
||||||
|
raise RuntimeError(f"Clone path not found: {clone_path}")
|
||||||
|
|
||||||
|
path = self._workspace_path(repo.id, name)
|
||||||
|
parent = os.path.dirname(path)
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.chmod(parent, 0o777)
|
||||||
|
|
||||||
|
if os.path.exists(path):
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
|
||||||
|
shutil.move(clone_path, path)
|
||||||
|
self._make_world_writable(path)
|
||||||
|
|
||||||
|
workspace = Workspace(
|
||||||
|
name=name,
|
||||||
|
repo_id=repo.id,
|
||||||
|
user_id=instance.owner_id,
|
||||||
|
branch=instance.branch or "main",
|
||||||
|
path=path,
|
||||||
|
status="ready",
|
||||||
|
last_sync_at=datetime.now(),
|
||||||
|
)
|
||||||
|
session.add(workspace)
|
||||||
|
await session.flush()
|
||||||
|
return workspace
|
||||||
|
|||||||
@@ -830,8 +830,6 @@ async def prepare_manifest_instance(
|
|||||||
|
|
||||||
# Prepare SSH path for mount resolution
|
# Prepare SSH path for mount resolution
|
||||||
ssh_path = ""
|
ssh_path = ""
|
||||||
if instance.clone_mode == "clone":
|
|
||||||
ssh_path = os.path.join(instance_dir, ".ssh")
|
|
||||||
|
|
||||||
# Resolve git mount variables from config profile
|
# Resolve git mount variables from config profile
|
||||||
git_mount_vars = {}
|
git_mount_vars = {}
|
||||||
@@ -848,6 +846,7 @@ async def prepare_manifest_instance(
|
|||||||
"IMAGE_TAG": image_tag,
|
"IMAGE_TAG": image_tag,
|
||||||
"INSTANCE_NAME": instance.name.lower(),
|
"INSTANCE_NAME": instance.name.lower(),
|
||||||
"INSTANCE_DIR": instance_dir,
|
"INSTANCE_DIR": instance_dir,
|
||||||
|
"WORKSPACE_PATH": repo_path,
|
||||||
"REPO_PATH": repo_path,
|
"REPO_PATH": repo_path,
|
||||||
"SSH_PATH": ssh_path,
|
"SSH_PATH": ssh_path,
|
||||||
"TOOL_PORT": instance.port or 0,
|
"TOOL_PORT": instance.port or 0,
|
||||||
@@ -922,13 +921,6 @@ async def create_tool_instance(
|
|||||||
if workspace.repo_id != repo_id:
|
if workspace.repo_id != repo_id:
|
||||||
raise ValueError("workspace does not belong to this repository")
|
raise ValueError("workspace does not belong to this repository")
|
||||||
|
|
||||||
# Validate clone mode requirements (legacy path)
|
|
||||||
if data.clone_mode == "clone" and not workspace:
|
|
||||||
if not repo.remote_url:
|
|
||||||
raise ValueError("repository does not have a remote URL for cloning")
|
|
||||||
if not repo.ssh_key_id:
|
|
||||||
raise ValueError("repository must have an SSH key assigned for clone mode")
|
|
||||||
|
|
||||||
# Generate unique name
|
# Generate unique name
|
||||||
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
|
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
@@ -970,71 +962,13 @@ async def create_tool_instance(
|
|||||||
# Find free port
|
# Find free port
|
||||||
tool_port = find_free_port()
|
tool_port = find_free_port()
|
||||||
|
|
||||||
# Determine repo path based on workspace or clone mode
|
# Determine the source path to mount. Workspaces are the canonical path;
|
||||||
|
# legacy instances without a workspace fall back to the repository path.
|
||||||
if workspace:
|
if workspace:
|
||||||
repo_path = workspace.path
|
repo_path = workspace.path
|
||||||
elif data.clone_mode == "clone":
|
|
||||||
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
|
|
||||||
if ssh_key is None:
|
|
||||||
raise ValueError("repository SSH key not found")
|
|
||||||
|
|
||||||
ssh_key_path = None
|
|
||||||
try:
|
|
||||||
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
|
|
||||||
ssh_key_path = os.path.join(ssh_dir, "id_ed25519")
|
|
||||||
|
|
||||||
clone_path = clone_repository(
|
|
||||||
remote_url=repo.remote_url,
|
|
||||||
ssh_key_path=ssh_key_path,
|
|
||||||
instance_dir=instance_dir,
|
|
||||||
branch=data.branch or "main",
|
|
||||||
)
|
|
||||||
repo_path = clone_path
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Failed to clone repository: %s", exc)
|
|
||||||
cleanup_ssh_key_files(instance_dir)
|
|
||||||
raise RuntimeError(f"Failed to clone repository: {exc}")
|
|
||||||
else:
|
else:
|
||||||
repo_path = repo.path
|
repo_path = repo.path
|
||||||
|
|
||||||
# Verify cloned repo has files
|
|
||||||
if data.clone_mode == "clone" and repo_path:
|
|
||||||
try:
|
|
||||||
repo_contents = os.listdir(repo_path)
|
|
||||||
if not repo_contents or (
|
|
||||||
len(repo_contents) == 1 and repo_contents[0] == ".git"
|
|
||||||
):
|
|
||||||
logger.error("Cloned repository at %s appears empty", repo_path)
|
|
||||||
raise RuntimeError("Cloned repository is empty")
|
|
||||||
logger.debug(
|
|
||||||
"Verified cloned repo at %s has %d items",
|
|
||||||
repo_path,
|
|
||||||
len(repo_contents),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Failed to verify cloned repository: %s", exc)
|
|
||||||
raise RuntimeError(f"Cloned repository verification failed: {exc}")
|
|
||||||
|
|
||||||
# Create new local branch if requested
|
|
||||||
if data.clone_mode == "clone" and data.new_branch:
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "-C", repo_path, "checkout", "-b", data.new_branch],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.error(
|
|
||||||
"Failed to create branch %s: %s", data.new_branch, result.stderr
|
|
||||||
)
|
|
||||||
raise RuntimeError(f"Failed to create branch: {result.stderr}")
|
|
||||||
logger.debug(
|
|
||||||
"Created local branch %s in cloned repository", data.new_branch
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Failed to create local branch: %s", exc)
|
|
||||||
raise RuntimeError(f"Failed to create local branch: {exc}")
|
|
||||||
|
|
||||||
# Handle based on definition type
|
# Handle based on definition type
|
||||||
if tool_type.definition_type == "dockerfile":
|
if tool_type.definition_type == "dockerfile":
|
||||||
image_tag = f"headquarter/{instance_name}:latest".lower()
|
image_tag = f"headquarter/{instance_name}:latest".lower()
|
||||||
@@ -1092,10 +1026,13 @@ async def create_tool_instance(
|
|||||||
|
|
||||||
image_tag = compute_image_tag(tool_type.name, manifest)
|
image_tag = compute_image_tag(tool_type.name, manifest)
|
||||||
|
|
||||||
|
# Manifest templates use WORKSPACE_PATH; REPO_PATH is retained as a
|
||||||
|
# deprecated alias for backward compatibility with older templates.
|
||||||
variables = {
|
variables = {
|
||||||
"IMAGE_TAG": image_tag,
|
"IMAGE_TAG": image_tag,
|
||||||
"INSTANCE_NAME": instance_name.lower(),
|
"INSTANCE_NAME": instance_name.lower(),
|
||||||
"INSTANCE_DIR": instance_dir,
|
"INSTANCE_DIR": instance_dir,
|
||||||
|
"WORKSPACE_PATH": repo_path,
|
||||||
"REPO_PATH": repo_path,
|
"REPO_PATH": repo_path,
|
||||||
"SSH_PATH": "",
|
"SSH_PATH": "",
|
||||||
"TOOL_PORT": tool_port,
|
"TOOL_PORT": tool_port,
|
||||||
@@ -1109,6 +1046,7 @@ async def create_tool_instance(
|
|||||||
if not tool_type.compose_template:
|
if not tool_type.compose_template:
|
||||||
raise ValueError("Tool type has no compose template configured")
|
raise ValueError("Tool type has no compose template configured")
|
||||||
variables = {
|
variables = {
|
||||||
|
"WORKSPACE_PATH": repo_path,
|
||||||
"REPO_PATH": repo_path,
|
"REPO_PATH": repo_path,
|
||||||
"INSTANCE_NAME": instance_name,
|
"INSTANCE_NAME": instance_name,
|
||||||
"INSTANCE_ID": instance_name,
|
"INSTANCE_ID": instance_name,
|
||||||
@@ -1121,37 +1059,6 @@ async def create_tool_instance(
|
|||||||
tool_type.compose_template, variables
|
tool_type.compose_template, variables
|
||||||
)
|
)
|
||||||
|
|
||||||
if data.clone_mode == "clone" and repo_path:
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
compose_data = yaml.safe_load(compose_content)
|
|
||||||
repo_mounted = False
|
|
||||||
if compose_data and "services" in compose_data:
|
|
||||||
for svc in compose_data["services"].values():
|
|
||||||
volumes = svc.get("volumes", [])
|
|
||||||
for vol in volumes:
|
|
||||||
vol_str = str(vol)
|
|
||||||
if repo_path in vol_str:
|
|
||||||
repo_mounted = True
|
|
||||||
break
|
|
||||||
if repo_mounted:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not repo_mounted:
|
|
||||||
logger.warning(
|
|
||||||
"Compose template for tool type %s does not mount repo path; adding default mount",
|
|
||||||
tool_type.name,
|
|
||||||
)
|
|
||||||
if compose_data and "services" in compose_data:
|
|
||||||
for svc in compose_data["services"].values():
|
|
||||||
if "volumes" not in svc:
|
|
||||||
svc["volumes"] = []
|
|
||||||
svc["volumes"].append(f"{repo_path}:/workspace")
|
|
||||||
break
|
|
||||||
compose_content = yaml.dump(
|
|
||||||
compose_data, default_flow_style=False
|
|
||||||
)
|
|
||||||
|
|
||||||
write_compose_file(instance_dir, compose_content)
|
write_compose_file(instance_dir, compose_content)
|
||||||
|
|
||||||
# Create database record
|
# Create database record
|
||||||
@@ -1166,10 +1073,8 @@ async def create_tool_instance(
|
|||||||
compose_path=compose_path,
|
compose_path=compose_path,
|
||||||
port=tool_port,
|
port=tool_port,
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
clone_mode=data.clone_mode,
|
clone_mode=None,
|
||||||
branch=data.new_branch
|
branch=None,
|
||||||
if data.new_branch
|
|
||||||
else (data.branch if data.clone_mode == "clone" else None),
|
|
||||||
selected_config_profile_id=selected_profile_id,
|
selected_config_profile_id=selected_profile_id,
|
||||||
ssh_key_ids=data.ssh_key_ids or None,
|
ssh_key_ids=data.ssh_key_ids or None,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user