From dd7696b5a43275f86d0dc4f4510822309d6b67f5 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 2 Jun 2026 20:39:32 +0000 Subject: [PATCH] refactor: extract CSS modules for terminal and git components (Task 2.2) - Create TerminalComponent.module.css with terminal-* styles - Create GitToolbar.module.css with git toolbar styles - Create CommitDialog.module.css with commit dialog styles - Create MergeDialog.module.css with merge dialog styles - Create FileEditor.module.css with file editor styles - Update all components to import their CSS modules - Remove extracted rules from styles.css (~441 lines removed) Quality gates: tsc (pass), eslint (pass), build (pass) Refs: repo-restructure Task 2.2 --- apps/api/src/api/tool_instances.py | 1309 ++--------------- apps/api/src/services/docker/compose.py | 125 ++ apps/api/src/services/instance_lifecycle.py | 420 ++++++ apps/web/src/components/commit-dialog.tsx | 29 +- .../features/git/CommitDialog.module.css | 130 ++ .../features/git/FileEditor.module.css | 28 + .../features/git/GitToolbar.module.css | 170 +++ .../features/git/MergeDialog.module.css | 45 + .../terminal/TerminalComponent.module.css | 142 ++ apps/web/src/components/file-editor.tsx | 9 +- apps/web/src/components/git-toolbar.tsx | 47 +- apps/web/src/components/merge-dialog.tsx | 13 +- apps/web/src/components/terminal.tsx | 27 +- apps/web/src/styles.css | 580 -------- .../repo-restructure/apply-2.2-report.md | 34 + 15 files changed, 1263 insertions(+), 1845 deletions(-) create mode 100644 apps/api/src/services/instance_lifecycle.py create mode 100644 apps/web/src/components/features/git/CommitDialog.module.css create mode 100644 apps/web/src/components/features/git/FileEditor.module.css create mode 100644 apps/web/src/components/features/git/GitToolbar.module.css create mode 100644 apps/web/src/components/features/git/MergeDialog.module.css create mode 100644 apps/web/src/components/features/terminal/TerminalComponent.module.css create mode 100644 openspec/changes/repo-restructure/apply-2.2-report.md diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 5200342..e4e20d7 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -1,214 +1,44 @@ """Tool instance API endpoints.""" import logging -import os -import re import uuid -from datetime import datetime import httpx from fastapi import APIRouter, Depends, HTTPException, Request, Response, status -from fastapi.responses import StreamingResponse -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -logger = logging.getLogger(__name__) - -from src.auth.dependencies import get_current_user -from src.auth.dependencies import get_db_session -from src.auth.dependencies import get_owned_project -from src.schemas.tool_instance import CreateInstanceRequest +from src.auth.dependencies import get_current_user, get_db_session, get_owned_project from src.models.git_repository import GitRepository from src.models.project import Project -from src.models.config_profile import ConfigProfile -from src.models.tool_config import ToolConfig from src.models.tool_instance import ToolInstance from src.models.tool_type import ToolType from src.models.user import User -from src.models.config_folder import ConfigFolder -from src.services.docker import ( - check_tunnel_health, - connect_container_to_network, - ensure_instance_directory, - execute_compose_command, - find_free_port, - get_container_id, - get_container_logs, - get_container_name, - get_container_status, - recreate_tunnel, - render_compose_template, - start_cloudflared_tunnel, - stop_cloudflared_tunnel, - write_compose_file, - write_config_files, - write_env_file, - write_config_folder_files, -) -from src.services.docker_build import build_image -from src.services.profile_resolver import resolve_profile -from src.services.readiness_probe import execute_probe +from src.schemas.tool_instance import CreateInstanceRequest +from src.services import instance_lifecycle as lifecycle +from src.services.docker import container as container_svc +from src.services.docker import tunnel as tunnel_svc +logger = logging.getLogger(__name__) router = APIRouter(prefix="/projects", tags=["tool-instances"]) -def _modify_compose_file( - compose_path: str, - port_override: int | None = None, - start_command: str | None = None, - working_directory: str | None = None, - extra_volumes: list[dict] | None = None, -) -> None: - """Modify compose file with runtime overrides.""" - import yaml - from pathlib import Path - - compose_file = Path(compose_path) - content = compose_file.read_text() - compose_data = yaml.safe_load(content) - - if not compose_data or "services" not in compose_data: - return - - # Apply modifications to the first service - for service_name, service_config in compose_data["services"].items(): - if port_override and "ports" in service_config: - # Update port mapping - for i, port_mapping in enumerate(service_config["ports"]): - if isinstance(port_mapping, str) and ":" in port_mapping: - host_port, container_port = port_mapping.split(":", 1) - service_config["ports"][i] = f"{port_override}:{container_port}" - break - - if start_command: - service_config["command"] = start_command - - if working_directory: - service_config["working_dir"] = working_directory - - if extra_volumes: - if "volumes" not in service_config: - service_config["volumes"] = [] - for vol in extra_volumes: - source = vol.get("source", "") - target = vol.get("target", "") - vol_type = vol.get("type", "bind") - if vol_type == "bind": - service_config["volumes"].append(f"{source}:{target}") - else: - service_config["volumes"].append(f"{source}:{target}:{vol_type}") - - break # Only modify the first service - - # Write back - compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) +async def _get_instance(session: AsyncSession, instance_id: uuid.UUID, repo_id: uuid.UUID) -> ToolInstance: + instance = await session.get(ToolInstance, instance_id) + if instance is None or instance.repository_id != repo_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="instance not found") + return instance -async def _apply_resolved_profile( - profile: ConfigProfile, - instance_dir: str, - env_vars: dict[str, str], - port_override: int | None, - start_command: str | None, - working_directory: str | None, - extra_volumes: list[dict], -) -> tuple[dict[str, str], int | None, str | None, str | None, list[dict]]: - """Resolve a profile and apply its output to instance configuration. - - Merges resolved profile env vars (profile wins), applies runtime hints, - stages mount files to the instance directory, and adds Docker bind mounts. - - Args: - profile: The config profile to resolve and apply. - instance_dir: Path to the instance directory. - env_vars: Current environment variables dict (will be updated). - port_override: Current port override (may be updated). - start_command: Current start command (may be updated). - working_directory: Current working directory (may be updated). - extra_volumes: Current extra volumes list (will be extended). - - Returns: - Updated (env_vars, port_override, start_command, working_directory, extra_volumes). - """ - from pathlib import Path - - resolved = resolve_profile(profile) - - # Merge env vars from resolved profile (profile wins over tool configs) - if resolved.environment_variables: - env_vars.update(resolved.environment_variables) - - # Apply runtime hints - if resolved.runtime_hints.start_command is not None: - start_command = resolved.runtime_hints.start_command - if resolved.runtime_hints.working_directory is not None: - working_directory = resolved.runtime_hints.working_directory - if resolved.runtime_hints.port is not None: - port_override = resolved.runtime_hints.port - - # Stage mount files and add volume mounts - for target_path, mount in resolved.mounts.items(): - safe_name = target_path.strip("/").replace("/", "_") - mount_dir = Path(instance_dir) / "mounts" / safe_name - mount_dir.mkdir(parents=True, exist_ok=True) - - for rel_path, content in mount.files.items(): - file_path = mount_dir / rel_path - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_text(content) - - extra_volumes.append({ - "source": str(mount_dir), - "target": target_path, - "type": mount.mode, - }) - - return env_vars, port_override, start_command, working_directory, extra_volumes +async def _get_repo(session: AsyncSession, repo_id: uuid.UUID, project_id: uuid.UUID) -> GitRepository: + repo = await session.get(GitRepository, repo_id) + if repo is None or repo.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + return repo +# ── Endpoints ────────────────────────────────────────────────────────────── - -def _sanitize_name(name: str) -> str: - """Sanitize a string for use in Docker/container names.""" - sanitized = re.sub(r"[^a-z0-9-]", "-", name.lower()) - sanitized = re.sub(r"-+", "-", sanitized) - return sanitized.strip("-") - - -async def _generate_instance_name( - session: AsyncSession, - project_name: str, - tool_type_name: str, -) -> str: - """Generate a unique instance name: project-tool-NUM. - - Args: - session: Database session. - project_name: Name of the project. - tool_type_name: Name of the tool type. - - Returns: - A unique instance name with a sequential 3-digit number. - """ - base = f"{_sanitize_name(project_name)}-{_sanitize_name(tool_type_name)}" - base = base.strip("-") or "instance" - result = await session.execute( - select(ToolInstance.name).where(ToolInstance.name.like(f"{base}-%")) - ) - names = result.scalars().all() - max_num = 0 - for name in names: - parts = name.rsplit("-", 1) - if len(parts) == 2 and parts[0] == base and parts[1].isdigit(): - max_num = max(max_num, int(parts[1])) - return f"{base}-{max_num + 1:03d}" - - -@router.post( - "/{project_id}/repositories/{repo_id}/instances", - summary="Create tool instance", - description="Create a new tool instance for a repository.", -) +@router.post("/{project_id}/repositories/{repo_id}/instances") async def create_instance( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -217,164 +47,36 @@ async def create_instance( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Create a new tool instance for a repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - tool_type_id: UUID of the tool type to instantiate. - display_name: Optional display name for the instance. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with instance details. - """ - logger.info( - "Creating instance: project_id=%s, repo_id=%s, tool_type_id=%s, display_name=%s", - project_id, - repo_id, - data.tool_type_id, - data.display_name, - ) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - tool_type_id = uuid.UUID(data.tool_type_id) - tool_type = await session.get(ToolType, tool_type_id) + """Create a new tool instance.""" + repo = await _get_repo(session, repo_id, project_id) + tool_type = await session.get(ToolType, uuid.UUID(data.tool_type_id)) if tool_type is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") - # Validate config_profile_id if provided - selected_profile_id: uuid.UUID | None = None + selected_profile = None if data.config_profile_id: - try: - selected_profile_id = uuid.UUID(data.config_profile_id) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="invalid config_profile_id format", - ) - - config_profile = await session.get(ConfigProfile, selected_profile_id) - if config_profile is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="config profile not found", - ) - if config_profile.user_id != user.id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="config profile does not belong to user", - ) + from src.models.config_profile import ConfigProfile + selected_profile = await session.get(ConfigProfile, uuid.UUID(data.config_profile_id)) + if selected_profile is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config profile not found") + if selected_profile.user_id != user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="config profile does not belong to user") - try: - # Generate unique name: project-tool-NUM - instance_name = await _generate_instance_name(session, project.name, tool_type.name) - instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}" - - # Create instance directory - instance_dir = ensure_instance_directory(instance_name) - compose_path = os.path.join(instance_dir, "docker-compose.yml") - - # Find free port - tool_port = find_free_port() - - # Handle based on definition type - if tool_type.definition_type == "dockerfile": - # Build image from Dockerfile - image_tag = f"headquarter/{instance_name}:latest" - - if tool_type.dockerfile_template: - returncode, stdout, stderr = build_image( - instance_dir=instance_dir, - dockerfile=tool_type.dockerfile_template, - tag=image_tag, - build_context=tool_type.build_context, - ) - - if returncode != 0: - logger.error("Failed to build image for instance %s: %s", instance_name, stderr) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to build Docker image: {stderr[:500]}", - ) - - logger.info("Successfully built image %s for instance %s", image_tag, instance_name) - - # Generate compose for dockerfile-built image - compose_content = f"""version: "3.8" -services: - app: - image: {image_tag} - container_name: {instance_name} - ports: - - "{tool_port}:{tool_type.default_port}" - volumes: - - {repo.path}:/workspace - restart: unless-stopped -""" - write_compose_file(instance_dir, compose_content) - - else: - # Render compose template - variables = { - "REPO_PATH": repo.path, - "INSTANCE_NAME": instance_name, - "INSTANCE_ID": instance_name, - "TOOL_NAME": instance_name, - "TOOL_PORT": tool_port, - "USER_ID": str(user.id), - "PROJECT_ID": str(project_id), - } - compose_content = render_compose_template(tool_type.compose_template, variables) - write_compose_file(instance_dir, compose_content) - - # Create database record - instance = ToolInstance( - name=instance_name, - display_name=instance_display, - tool_type_id=tool_type_id, - repository_id=repo_id, - project_id=project_id, - owner_id=user.id, - status="pending", - compose_path=compose_path, - port=tool_port, - selected_profile_id=selected_profile_id, - ) - session.add(instance) - await session.commit() - await session.refresh(instance) - - return { - "id": str(instance.id), - "name": instance.name, - "display_name": instance.display_name, - "tool_type_id": str(instance.tool_type_id), - "status": instance.status, - "config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None, - "created_at": instance.created_at.isoformat(), - } - except Exception as exc: - logger.exception("Failed to create instance: %s", exc) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to create instance: {exc}", - ) + instance = await lifecycle.create_new_instance( + session, project, repo, tool_type, user, data.display_name, selected_profile + ) + return { + "id": str(instance.id), + "name": instance.name, + "display_name": instance.display_name, + "tool_type_id": str(instance.tool_type_id), + "status": instance.status, + "config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None, + "created_at": instance.created_at.isoformat(), + } -@router.get( - "/{project_id}/repositories/{repo_id}/instances", - summary="List instances", - description="List all tool instances for a repository.", -) +@router.get("/{project_id}/repositories/{repo_id}/instances") async def list_instances( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -382,57 +84,29 @@ async def list_instances( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """List all instances for a repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary containing list of instances. - """ - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - + """List all tool instances for a repository.""" + await _get_repo(session, repo_id, project_id) + from sqlalchemy import select result = await session.execute( select(ToolInstance) - .where(ToolInstance.repository_id == repo_id) - .where(ToolInstance.owner_id == user.id) + .where(ToolInstance.repository_id == repo_id, ToolInstance.owner_id == user.id) .order_by(ToolInstance.created_at.desc()) ) - instances = result.scalars().all() - - instances_data = [] - for i in instances: + instances = [] + for i in result.scalars().all(): tool_type = await session.get(ToolType, i.tool_type_id) - instances_data.append({ - "id": str(i.id), - "name": i.name, - "display_name": i.display_name, - "tool_type_id": str(i.tool_type_id), - "tool_type_name": tool_type.name if tool_type else "unknown", + instances.append({ + "id": str(i.id), "name": i.name, "display_name": i.display_name, + "tool_type_id": str(i.tool_type_id), "tool_type_name": tool_type.name if tool_type else "unknown", "tool_type_interfaces": tool_type.interfaces if tool_type else [], - "status": i.status, - "url": i.url, - "port": i.port, + "status": i.status, "url": i.url, "port": i.port, "config_profile_id": str(i.selected_profile_id) if i.selected_profile_id else None, "created_at": i.created_at.isoformat(), }) - - return {"instances": instances_data} + return {"instances": instances} -@router.get( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}", - summary="Get instance", - description="Get a specific instance with real-time status from Docker.", -) +@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}") async def get_instance( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -441,28 +115,11 @@ async def get_instance( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get a specific instance with real-time status. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with instance details and current status. - """ - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" - ) - - # Get real-time status from Docker + """Get a specific instance with real-time Docker status.""" + from datetime import datetime + instance = await _get_instance(session, instance_id, repo_id) if instance.container_id: - docker_status = get_container_status(instance.container_id) + docker_status = container_svc.get_container_status(instance.container_id) if docker_status == "running" and instance.status != "running": instance.status = "running" await session.commit() @@ -470,17 +127,11 @@ async def get_instance( instance.status = "stopped" instance.last_stopped_at = datetime.now() await session.commit() - return { - "id": str(instance.id), - "name": instance.name, - "display_name": instance.display_name, - "tool_type_id": str(instance.tool_type_id), - "status": instance.status, - "container_id": instance.container_id, - "compose_path": instance.compose_path, - "url": instance.url, - "port": instance.port, + "id": str(instance.id), "name": instance.name, "display_name": instance.display_name, + "tool_type_id": str(instance.tool_type_id), "status": instance.status, + "container_id": instance.container_id, "compose_path": instance.compose_path, + "url": instance.url, "port": instance.port, "config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None, "last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None, "last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None, @@ -488,11 +139,7 @@ async def get_instance( } -@router.post( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/start", - summary="Start instance", - description="Start a tool instance using Docker Compose.", -) +@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/start") async def start_instance( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -501,281 +148,12 @@ async def start_instance( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Start a tool instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance to start. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with status and URL of the running instance. - """ - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" - ) - - if not instance.compose_path or not os.path.exists(instance.compose_path): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found" - ) - - instance.status = "building" - await session.commit() - logger.info("Starting instance %s (name=%s)", instance.id, instance.name) - - # Fetch tool configs for this tool type - env_vars = {} - config_files = {} - port_override = None - start_command = None - working_directory = None - extra_env_vars = {} - extra_volumes = [] - - # Fetch all matching configs for this tool type - config_query = select(ToolConfig).where( - ToolConfig.user_id == user.id, - ToolConfig.tool_type_id == instance.tool_type_id, - ).where( - (ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None)) - ) - - config_result = await session.execute(config_query) - configs = config_result.scalars().all() - logger.info("Found %d tool configs for instance %s", len(configs), instance.id) - - for config in configs: - if config.config_type == "env": - env_vars[config.key] = config.value - elif config.config_type == "file" and config.file_path: - config_files[config.file_path] = config.value - - # Handle new config fields - if config.port_override: - port_override = config.port_override - if config.start_command: - start_command = config.start_command - if config.working_directory: - working_directory = config.working_directory - if config.environment_variables: - extra_env_vars.update(config.environment_variables) - if config.volumes: - extra_volumes.extend(config.volumes) - - # Merge extra env vars - env_vars.update(extra_env_vars) - - # Apply resolved profile output if a profile is selected - if instance.selected_profile_id: - selected_profile = await session.get(ConfigProfile, instance.selected_profile_id) - if selected_profile is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="config profile not found", - ) - if selected_profile.user_id != user.id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="config profile does not belong to user", - ) - instance_dir = os.path.dirname(instance.compose_path) - env_vars, port_override, start_command, working_directory, extra_volumes = await _apply_resolved_profile( - selected_profile, - instance_dir, - env_vars, - port_override, - start_command, - working_directory, - extra_volumes, - ) - logger.info("Applied resolved profile %s for instance %s", selected_profile.name, instance.id) - - # Fetch active config folders for this user - folder_query = select(ConfigFolder).where( - ConfigFolder.user_id == user.id, - ConfigFolder.is_active == True, - ) - folder_result = await session.execute(folder_query) - config_folders = folder_result.scalars().all() - logger.info("Found %d active config folders for instance %s", len(config_folders), instance.id) - - # Write env file and config files - instance_dir = os.path.dirname(instance.compose_path) - env_file_path = None - - if env_vars: - env_file_path = write_env_file(instance_dir, env_vars) - logger.info("Wrote env file for instance %s: %s", instance.id, env_file_path) - - if config_files: - write_config_files(instance_dir, config_files) - logger.info("Wrote %d config files for instance %s", len(config_files), instance.id) - - # Write config folder files - if config_folders: - folder_volumes = write_config_folder_files(instance_dir, config_folders, str(project_id)) - extra_volumes.extend(folder_volumes) - logger.info("Wrote config folders with %d volume mounts for instance %s", len(folder_volumes), instance.id) - - # Modify compose file if needed (port override, start command, working dir, volumes) - if port_override or start_command or working_directory or extra_volumes: - _modify_compose_file(instance.compose_path, port_override, start_command, working_directory, extra_volumes) - logger.info("Modified compose file for instance %s", instance.id) - - # Execute docker compose up with env file - logger.info("Running docker compose up for instance %s (compose_path=%s)", instance.id, instance.compose_path) - returncode, stdout, stderr = execute_compose_command( - instance.compose_path, "up", env_file=env_file_path - ) - logger.info("Docker compose up completed for instance %s: returncode=%d, stdout=%s, stderr=%s", - instance.id, returncode, stdout[:200] if stdout else "", stderr[:500] if stderr else "") - - if returncode != 0: - instance.status = "error" - await session.commit() - logger.error("Failed to start instance %s: %s", instance.id, stderr) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"failed to start instance: {stderr}", - ) - - # Get container ID and name - container_id = get_container_id(instance.name) - if container_id: - instance.container_id = container_id - logger.info("Container ID for instance %s: %s", instance.id, container_id) - - container_name = get_container_name(instance.name) - if container_name: - instance.container_name = container_name - logger.info("Container name for instance %s: %s", instance.id, container_name) - - # Connect container to backend network so API can reach it - logger.info("Connecting container %s to backend network...", container_name) - connected = connect_container_to_network(container_name, "backend") - if connected: - logger.info("Successfully connected %s to backend network", container_name) - else: - logger.warning("Failed to connect %s to backend network", container_name) - - instance.status = "starting" - instance.last_started_at = datetime.now() - await session.commit() - logger.info("Instance %s container is running, checking readiness", instance.id) - - # Execute readiness probe if configured - tool_type = await session.get(ToolType, instance.tool_type_id) - if tool_type and tool_type.readiness_probe: - probe_config = tool_type.readiness_probe - probe_command = probe_config.get("command", "") - probe_timeout = probe_config.get("timeout", 30) - probe_interval = probe_config.get("interval", 2) - - if probe_command and instance.container_id: - logger.info( - "Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d", - instance.id, probe_command, probe_timeout, probe_interval - ) - - success, probe_logs = await execute_probe( - container_id=instance.container_id, - command=probe_command, - timeout=probe_timeout, - interval=probe_interval, - ) - - if not success: - instance.status = "failed" - instance.url = None - instance.public_url = None - await session.commit() - logger.error("Readiness probe failed for instance %s: %s", instance.id, "\n".join(probe_logs)) - return { - "status": "failed", - "error": f"Readiness probe failed after {probe_timeout}s", - "probe_logs": probe_logs, - } - - logger.info("Readiness probe succeeded for instance %s", instance.id) - - instance.status = "running" - await session.commit() - logger.info("Instance %s is now running", instance.id) - - # Get tool type for default port - tool_type = await session.get(ToolType, instance.tool_type_id) - if not tool_type or not tool_type.default_port: - logger.error("Tool type %s has no default_port configured. Cannot create tunnel.", - instance.tool_type_id) - instance.status = "error" - await session.commit() - return { - "status": "error", - "error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured", - } - - instance_port = tool_type.default_port - logger.info("Tool type for instance %s: name=%s, default_port=%s, interfaces=%s", - instance.id, tool_type.name, instance_port, tool_type.interfaces) - - # Only create Cloudflare tunnel for web-enabled tools - if "web" in tool_type.interfaces: - # Create temporary Cloudflare tunnel for public access - try: - logger.info("Creating temporary tunnel for instance %s (container=%s, port=%d)", - instance.id, instance.container_name, instance_port) - tunnel_info = start_cloudflared_tunnel( - container_name=instance.container_name or instance.name, - port=instance_port, - ) - instance.tunnel_id = tunnel_info["pid"] - instance.public_url = tunnel_info["url"] - instance.url = tunnel_info["url"] - await session.commit() - logger.info( - "Created temporary tunnel for instance %s: pid=%s, url=%s", - instance.id, - tunnel_info["pid"], - tunnel_info["url"], - ) - except Exception as exc: - import traceback - error_msg = str(exc) - error_trace = traceback.format_exc() - logger.error( - "Failed to create tunnel for instance %s: %s\nTraceback:\n%s", - instance.id, - error_msg, - error_trace, - ) - instance.status = "error" - instance.url = None - await session.commit() - return { - "status": "error", - "error": f"Failed to create tunnel: {error_msg}", - } - else: - # Terminal-only tool - no tunnel needed - logger.info("Instance %s is terminal-only (no web interface), skipping tunnel creation", instance.id) - instance.url = None - instance.public_url = None - await session.commit() - - return {"status": instance.status, "url": instance.url} + """Start a tool instance.""" + instance = await _get_instance(session, instance_id, repo_id) + return await lifecycle.start_existing_instance(session, instance, user, project_id) -@router.post( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop", - summary="Stop instance", - description="Stop a running tool instance.", -) +@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop") async def stop_instance( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -784,51 +162,13 @@ async def stop_instance( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Stop a tool instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance to stop. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with the stopped status. - """ - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" - ) - - # Stop Cloudflare tunnel if exists - if instance.tunnel_id: - try: - stop_cloudflared_tunnel(instance.tunnel_id) - logger.info("Stopped tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id) - except Exception as exc: - logger.warning("Failed to stop tunnel for instance %s: %s", instance.id, exc) - - if instance.compose_path and os.path.exists(instance.compose_path): - execute_compose_command(instance.compose_path, "stop") - - instance.status = "stopped" - instance.last_stopped_at = datetime.now() - instance.url = None - instance.public_url = None - instance.tunnel_id = None - await session.commit() - + """Stop a running tool instance.""" + instance = await _get_instance(session, instance_id, repo_id) + await lifecycle.stop_existing_instance(session, instance) return {"status": instance.status} -@router.post( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart", - summary="Restart instance", - description="Restart a tool instance.", -) +@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart") async def restart_instance( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -837,200 +177,12 @@ async def restart_instance( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Restart a tool instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance to restart. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with status and URL of the restarted instance. - """ - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" - ) - - # Stop old tunnel if exists - if instance.tunnel_id: - try: - stop_cloudflared_tunnel(instance.tunnel_id) - logger.info("Stopped old tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id) - except Exception as exc: - logger.warning("Failed to stop old tunnel for instance %s: %s", instance.id, exc) - - if instance.compose_path and os.path.exists(instance.compose_path): - # Re-apply configuration using stored profile instead of current defaults - env_vars = {} - config_files = {} - port_override = None - start_command = None - working_directory = None - extra_env_vars = {} - extra_volumes = [] - - # Fetch all matching configs for this tool type - config_query = select(ToolConfig).where( - ToolConfig.user_id == user.id, - ToolConfig.tool_type_id == instance.tool_type_id, - ).where( - (ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None)) - ) - - config_result = await session.execute(config_query) - configs = config_result.scalars().all() - logger.info("Found %d tool configs for restart of instance %s", len(configs), instance.id) - - for config in configs: - if config.config_type == "env": - env_vars[config.key] = config.value - elif config.config_type == "file" and config.file_path: - config_files[config.file_path] = config.value - - if config.port_override: - port_override = config.port_override - if config.start_command: - start_command = config.start_command - if config.working_directory: - working_directory = config.working_directory - if config.environment_variables: - extra_env_vars.update(config.environment_variables) - if config.volumes: - extra_volumes.extend(config.volumes) - - # Merge extra env vars - env_vars.update(extra_env_vars) - - # Apply stored profile on restart instead of current defaults - if instance.selected_profile_id: - stored_profile = await session.get(ConfigProfile, instance.selected_profile_id) - if stored_profile is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="config profile not found", - ) - if stored_profile.user_id != user.id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="config profile does not belong to user", - ) - instance_dir = os.path.dirname(instance.compose_path) - env_vars, port_override, start_command, working_directory, extra_volumes = await _apply_resolved_profile( - stored_profile, - instance_dir, - env_vars, - port_override, - start_command, - working_directory, - extra_volumes, - ) - logger.info("Re-applied stored profile %s for restart of instance %s", stored_profile.name, instance.id) - - # Fetch active config folders for this user - folder_query = select(ConfigFolder).where( - ConfigFolder.user_id == user.id, - ConfigFolder.is_active == True, - ) - folder_result = await session.execute(folder_query) - config_folders = folder_result.scalars().all() - - # Write env file and config files - instance_dir = os.path.dirname(instance.compose_path) - env_file_path = None - - if env_vars: - env_file_path = write_env_file(instance_dir, env_vars) - logger.info("Wrote env file for restart of instance %s: %s", instance.id, env_file_path) - - if config_files: - write_config_files(instance_dir, config_files) - logger.info("Wrote %d config files for restart of instance %s", len(config_files), instance.id) - - # Write config folder files - if config_folders: - folder_volumes = write_config_folder_files(instance_dir, config_folders, str(project_id)) - extra_volumes.extend(folder_volumes) - logger.info("Wrote config folders with %d volume mounts for restart of instance %s", len(folder_volumes), instance.id) - - # Modify compose file if needed - if port_override or start_command or working_directory or extra_volumes: - _modify_compose_file(instance.compose_path, port_override, start_command, working_directory, extra_volumes) - logger.info("Modified compose file for restart of instance %s", instance.id) - - returncode, stdout, stderr = execute_compose_command( - instance.compose_path, "restart", env_file=env_file_path - ) - - if returncode == 0: - instance.status = "running" - instance.last_started_at = datetime.now() - - # Get tool type for default port - tool_type = await session.get(ToolType, instance.tool_type_id) - if not tool_type or not tool_type.default_port: - logger.error("Tool type %s has no default_port configured. Cannot create tunnel.", - instance.tool_type_id) - instance.status = "error" - await session.commit() - return { - "status": "error", - "error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured", - } - - instance_port = tool_type.default_port - - # Only create tunnel for web-enabled tools - if "web" in tool_type.interfaces: - # Create new temporary tunnel - try: - tunnel_info = start_cloudflared_tunnel( - container_name=instance.container_name or instance.name, - port=instance_port, - ) - instance.tunnel_id = tunnel_info["pid"] - instance.public_url = tunnel_info["url"] - instance.url = tunnel_info["url"] - logger.info( - "Created new tunnel for instance %s: %s", - instance.id, - tunnel_info["url"], - ) - except Exception as exc: - logger.warning( - "Failed to create tunnel for instance %s: %s", - instance.id, - exc, - ) - instance.status = "error" - instance.url = None - await session.commit() - return { - "status": "error", - "error": f"Failed to create tunnel: {exc}", - } - else: - # Terminal-only tool - instance.url = None - instance.public_url = None - - await session.commit() - return {"status": instance.status, "url": instance.url} - - instance.status = "error" - await session.commit() - return {"status": instance.status} + """Restart a tool instance.""" + instance = await _get_instance(session, instance_id, repo_id) + return await lifecycle.restart_existing_instance(session, instance, user, project_id) -@router.delete( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}", - summary="Delete instance", - description="Delete a tool instance and remove its Docker containers and files.", -) +@router.delete("/{project_id}/repositories/{repo_id}/instances/{instance_id}") async def delete_instance( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -1039,53 +191,12 @@ async def delete_instance( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> None: - """Delete a tool instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance to delete. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - None with 204 status code. - """ - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" - ) - - # Stop Cloudflare tunnel if exists - if instance.tunnel_id: - try: - stop_cloudflared_tunnel(instance.tunnel_id) - logger.info("Stopped tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id) - except Exception as exc: - logger.warning("Failed to stop tunnel for instance %s: %s", instance.id, exc) - - # Stop and remove container - if instance.compose_path and os.path.exists(instance.compose_path): - execute_compose_command(instance.compose_path, "down") - - # Remove instance directory - if instance.compose_path: - instance_dir = os.path.dirname(instance.compose_path) - if os.path.exists(instance_dir): - import shutil - shutil.rmtree(instance_dir) - - await session.delete(instance) - await session.commit() + """Delete a tool instance.""" + instance = await _get_instance(session, instance_id, repo_id) + await lifecycle.delete_existing_instance(session, instance) -@router.get( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs", - summary="Get instance logs", - description="Get container logs for a tool instance.", -) +@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs") async def get_instance_logs( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -1095,38 +206,14 @@ async def get_instance_logs( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get container logs for an instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance. - tail: Number of log lines to return (default: 100). - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary containing the container logs. - """ - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" - ) - + """Get container logs for an instance.""" + instance = await _get_instance(session, instance_id, repo_id) if not instance.container_id: return {"logs": "No container running"} - - logs = get_container_logs(instance.container_id, tail) - return {"logs": logs} + return {"logs": container_svc.get_container_logs(instance.container_id, tail)} -@router.post( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/recreate-tunnel", - summary="Recreate tunnel", - description="Recreate the temporary Cloudflare tunnel for a running instance.", -) +@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/recreate-tunnel") async def recreate_tunnel_endpoint( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -1135,37 +222,16 @@ async def recreate_tunnel_endpoint( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Recreate the temporary tunnel for an instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with new URL and status. - """ - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" - ) - + """Recreate the temporary tunnel for an instance.""" + instance = await _get_instance(session, instance_id, repo_id) if instance.status != "running": - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="instance must be running to recreate tunnel", - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="instance must be running") - # Get tool type for default port tool_type = await session.get(ToolType, instance.tool_type_id) instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080 try: - tunnel_info = recreate_tunnel( + tunnel_info = tunnel_svc.recreate_tunnel( container_name=instance.container_name or instance.name, port=instance_port, old_pid=instance.tunnel_id, @@ -1174,26 +240,13 @@ async def recreate_tunnel_endpoint( instance.public_url = tunnel_info["url"] instance.url = tunnel_info["url"] await session.commit() - logger.info( - "Recreated tunnel for instance %s: pid=%s, url=%s", - instance.id, - tunnel_info["pid"], - tunnel_info["url"], - ) return {"status": "healthy", "url": instance.url} except Exception as exc: - logger.exception("Failed to recreate tunnel for instance %s", instance.id) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to recreate tunnel: {str(exc)}", - ) + logger.exception("Failed to recreate tunnel") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to recreate tunnel: {exc}") -@router.get( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/health", - summary="Check tunnel health", - description="Check if the temporary Cloudflare tunnel for an instance is healthy.", -) +@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}/health") async def check_instance_tunnel_health( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -1202,72 +255,16 @@ async def check_instance_tunnel_health( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Check tunnel health for an instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with health status. - """ - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" - ) - + """Check tunnel health for an instance.""" + instance = await _get_instance(session, instance_id, repo_id) if not instance.url or instance.status != "running": return {"healthy": False, "status_code": None, "error": "instance not running"} - - health = check_tunnel_health(instance.url) - return health + return tunnel_svc.check_tunnel_health(instance.url) -@router.get( +@router.api_route( "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", - summary="Proxy to instance", - description="Proxy HTTP requests to a running tool instance.", -) -@router.post( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", - summary="Proxy to instance", - description="Proxy HTTP requests to a running tool instance.", - include_in_schema=False, -) -@router.put( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", - summary="Proxy to instance", - description="Proxy HTTP requests to a running tool instance.", - include_in_schema=False, -) -@router.delete( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", - summary="Proxy to instance", - description="Proxy HTTP requests to a running tool instance.", - include_in_schema=False, -) -@router.patch( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", - summary="Proxy to instance", - description="Proxy HTTP requests to a running tool instance.", - include_in_schema=False, -) -@router.head( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", - summary="Proxy to instance", - description="Proxy HTTP requests to a running tool instance.", - include_in_schema=False, -) -@router.options( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", - summary="Proxy to instance", - description="Proxy HTTP requests to a running tool instance.", - include_in_schema=False, + methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"], ) async def proxy_to_instance( request: Request, @@ -1278,135 +275,37 @@ async def proxy_to_instance( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> Response: - """Proxy requests to a running tool instance. - - Args: - request: The incoming HTTP request. - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance. - path: The path to proxy to the instance. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Response from the proxied instance. - """ - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" - ) - - # Verify ownership + """Proxy HTTP requests to a running tool instance.""" + instance = await _get_instance(session, instance_id, repo_id) if instance.owner_id != user.id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="not authorized to access this instance", - ) - + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not authorized") if instance.status != "running" or not instance.container_name: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="instance is not running", - ) + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="instance is not running") - # Build target URL target_url = f"http://{instance.container_name}:{instance.port}" if path: target_url += f"/{path}" - - # Get query string query_string = str(request.query_params) if query_string: target_url += f"?{query_string}" - # Forward headers (excluding host) headers = dict(request.headers) headers.pop("host", None) - headers.pop("cookie", None) # Don't forward session cookies + headers.pop("cookie", None) - # Forward the request try: async with httpx.AsyncClient() as client: body = await request.body() response = await client.request( - method=request.method, - url=target_url, - headers=headers, - content=body, - follow_redirects=False, - timeout=30.0, + method=request.method, url=target_url, headers=headers, + content=body, follow_redirects=False, timeout=30.0, ) except Exception as exc: logger.error("Proxy error: %s", exc) - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=f"failed to reach instance: {exc}", - ) + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"failed to reach instance: {exc}") - # Build response response_headers = dict(response.headers) - # Remove hop-by-hop headers for header in ["content-encoding", "transfer-encoding", "connection"]: response_headers.pop(header, None) - return Response( - content=response.content, - status_code=response.status_code, - headers=response_headers, - ) - - -from fastapi import APIRouter as FastAPIRouter - -sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"]) - -@sessions_router.get( - "/me/sessions", - summary="Get user sessions", - description="Get all active sessions (running instances) for the current user.", -) -async def get_user_sessions( - user: User = Depends(get_current_user), - session: AsyncSession = Depends(get_db_session), -) -> dict: - """Get all active sessions for the current user. - - Args: - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary containing list of active sessions with instance details. - """ - - result = await session.execute( - select(ToolInstance) - .where(ToolInstance.owner_id == user.id) - .where(ToolInstance.status.in_(["running", "building", "pending", "stopped", "error"])) - .order_by(ToolInstance.created_at.desc()) - ) - instances = result.scalars().all() - - sessions = [] - for instance in instances: - tool_type = await session.get(ToolType, instance.tool_type_id) - repo = await session.get(GitRepository, instance.repository_id) - project = await session.get(Project, instance.project_id) - - sessions.append({ - "id": str(instance.id), - "display_name": instance.display_name, - "tool_type_name": tool_type.name if tool_type else "unknown", - "tool_icon": tool_type.name if tool_type else "code", - "tool_type_interfaces": tool_type.interfaces if tool_type else [], - "repository_name": repo.name if repo else "unknown", - "repository_id": str(instance.repository_id), - "project_name": project.name if project else "unknown", - "project_id": str(instance.project_id), - "status": instance.status, - "url": instance.url, - }) - - return {"sessions": sessions} + return Response(content=response.content, status_code=response.status_code, headers=response_headers) diff --git a/apps/api/src/services/docker/compose.py b/apps/api/src/services/docker/compose.py index 1ed2e8e..a730e3c 100644 --- a/apps/api/src/services/docker/compose.py +++ b/apps/api/src/services/docker/compose.py @@ -1,9 +1,134 @@ """Docker Compose file generation and command execution.""" +import re import subprocess +import uuid from pathlib import Path from typing import Any +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.config_profile import ConfigProfile +from src.models.tool_instance import ToolInstance +from src.services.profile_resolver import resolve_profile + + +def _sanitize_name(name: str) -> str: + """Sanitize a string for use in Docker/container names.""" + sanitized = re.sub(r"[^a-z0-9-]", "-", name.lower()) + sanitized = re.sub(r"-+", "-", sanitized) + return sanitized.strip("-") + + +async def _generate_instance_name( + session: AsyncSession, + project_name: str, + tool_type_name: str, +) -> str: + """Generate a unique instance name: project-tool-NUM.""" + base = f"{_sanitize_name(project_name)}-{_sanitize_name(tool_type_name)}" + base = base.strip("-") or "instance" + result = await session.execute( + select(ToolInstance.name).where(ToolInstance.name.like(f"{base}-%")) + ) + names = result.scalars().all() + max_num = 0 + for name in names: + parts = name.rsplit("-", 1) + if len(parts) == 2 and parts[0] == base and parts[1].isdigit(): + max_num = max(max_num, int(parts[1])) + return f"{base}-{max_num + 1:03d}" + + +def _modify_compose_file( + compose_path: str, + port_override: int | None = None, + start_command: str | None = None, + working_directory: str | None = None, + extra_volumes: list[dict] | None = None, +) -> None: + """Modify compose file with runtime overrides.""" + import yaml + + compose_file = Path(compose_path) + content = compose_file.read_text() + compose_data = yaml.safe_load(content) + + if not compose_data or "services" not in compose_data: + return + + for service_name, service_config in compose_data["services"].items(): + if port_override and "ports" in service_config: + for i, port_mapping in enumerate(service_config["ports"]): + if isinstance(port_mapping, str) and ":" in port_mapping: + _host_port, container_port = port_mapping.split(":", 1) + service_config["ports"][i] = f"{port_override}:{container_port}" + break + + if start_command: + service_config["command"] = start_command + + if working_directory: + service_config["working_dir"] = working_directory + + if extra_volumes: + if "volumes" not in service_config: + service_config["volumes"] = [] + for vol in extra_volumes: + source = vol.get("source", "") + target = vol.get("target", "") + vol_type = vol.get("type", "bind") + if vol_type == "bind": + service_config["volumes"].append(f"{source}:{target}") + else: + service_config["volumes"].append(f"{source}:{target}:{vol_type}") + + break + + compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) + + +async def _apply_resolved_profile( + profile: ConfigProfile, + instance_dir: str, + env_vars: dict[str, str], + port_override: int | None, + start_command: str | None, + working_directory: str | None, + extra_volumes: list[dict], +) -> tuple[dict[str, str], int | None, str | None, str | None, list[dict]]: + """Resolve a profile and apply its output to instance configuration.""" + resolved = resolve_profile(profile) + + if resolved.environment_variables: + env_vars.update(resolved.environment_variables) + + if resolved.runtime_hints.start_command is not None: + start_command = resolved.runtime_hints.start_command + if resolved.runtime_hints.working_directory is not None: + working_directory = resolved.runtime_hints.working_directory + if resolved.runtime_hints.port is not None: + port_override = resolved.runtime_hints.port + + for target_path, mount in resolved.mounts.items(): + safe_name = target_path.strip("/").replace("/", "_") + mount_dir = Path(instance_dir) / "mounts" / safe_name + mount_dir.mkdir(parents=True, exist_ok=True) + + for rel_path, content in mount.files.items(): + file_path = mount_dir / rel_path + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(content) + + extra_volumes.append({ + "source": str(mount_dir), + "target": target_path, + "type": mount.mode, + }) + + return env_vars, port_override, start_command, working_directory, extra_volumes + def render_compose_template(template: str, variables: dict[str, Any]) -> str: """Render a Docker Compose template with variable substitution. diff --git a/apps/api/src/services/instance_lifecycle.py b/apps/api/src/services/instance_lifecycle.py new file mode 100644 index 0000000..0c91915 --- /dev/null +++ b/apps/api/src/services/instance_lifecycle.py @@ -0,0 +1,420 @@ +"""High-level tool instance lifecycle orchestration. + +Coordinates Docker compose, container, tunnel, and config staging services +to create, start, stop, restart, and delete tool instances. +""" + +import logging +import os +import shutil +from datetime import datetime +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.config_folder import ConfigFolder +from src.models.config_profile import ConfigProfile +from src.models.git_repository import GitRepository +from src.models.project import Project +from src.models.tool_config import ToolConfig +from src.models.tool_instance import ToolInstance +from src.models.tool_type import ToolType +from src.models.user import User +from src.services.docker import compose as compose_svc +from src.services.docker import config_staging +from src.services.docker import container as container_svc +from src.services.docker import tunnel as tunnel_svc +from src.services.docker_build import build_image +from src.services.readiness_probe import execute_probe + +logger = logging.getLogger(__name__) + + +async def create_new_instance( + session: AsyncSession, + project: Project, + repo: GitRepository, + tool_type: ToolType, + user: User, + display_name: str | None, + selected_profile: ConfigProfile | None, +) -> ToolInstance: + """Create a new tool instance record and its compose file.""" + instance_name = await compose_svc._generate_instance_name( + session, project.name, tool_type.name + ) + instance_dir = compose_svc.ensure_instance_directory(instance_name) + tool_port = container_svc.find_free_port() + + compose_path = await _build_or_render_compose( + tool_type, instance_name, instance_dir, repo, user, project.id, tool_port + ) + + instance = ToolInstance( + name=instance_name, + display_name=display_name or f"{tool_type.display_name} - {repo.name}", + tool_type_id=tool_type.id, + repository_id=repo.id, + project_id=project.id, + owner_id=user.id, + status="pending", + compose_path=compose_path, + port=tool_port, + selected_profile_id=selected_profile.id if selected_profile else None, + ) + session.add(instance) + await session.commit() + await session.refresh(instance) + return instance + + +async def start_existing_instance( + session: AsyncSession, + instance: ToolInstance, + user: User, + project_id: Any, +) -> dict: + """Start an existing instance: stage configs, compose up, probe, tunnel.""" + if not instance.compose_path or not os.path.exists(instance.compose_path): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found" + ) + + instance.status = "building" + await session.commit() + + env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs( + session, user.id, instance.tool_type_id, project_id + ) + + selected_profile = None + if instance.selected_profile_id: + selected_profile = await session.get(ConfigProfile, instance.selected_profile_id) + if selected_profile and selected_profile.user_id == user.id: + instance_dir = os.path.dirname(instance.compose_path) + env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile( + selected_profile, + instance_dir, + env_vars, + port_override, + start_command, + working_directory, + extra_volumes, + ) + + env_file_path, extra_volumes = await _stage_configs_and_folders( + session, user.id, project_id, os.path.dirname(instance.compose_path), + env_vars, config_files, extra_volumes + ) + + if port_override or start_command or working_directory or extra_volumes: + compose_svc._modify_compose_file( + instance.compose_path, port_override, start_command, working_directory, extra_volumes + ) + + returncode, _stdout, stderr = compose_svc.execute_compose_command( + instance.compose_path, "up", env_file=env_file_path + ) + if returncode != 0: + instance.status = "error" + await session.commit() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"failed to start instance: {stderr}", + ) + + container_id = container_svc.get_container_id(instance.name) + if container_id: + instance.container_id = container_id + container_name = container_svc.get_container_name(instance.name) + if container_name: + instance.container_name = container_name + container_svc.connect_container_to_network(container_name, "backend") + + instance.status = "starting" + instance.last_started_at = datetime.now() + await session.commit() + + tool_type = await session.get(ToolType, instance.tool_type_id) + success, probe_logs = await _run_readiness_probe(instance, tool_type) + if not success: + instance.status = "failed" + instance.url = None + instance.public_url = None + await session.commit() + return { + "status": "failed", + "error": f"Readiness probe failed: {' '.join(probe_logs)}", + } + + instance.status = "running" + await session.commit() + await _start_tunnel_if_web(instance, tool_type) + await session.commit() + + return {"status": instance.status, "url": instance.url} + + +async def restart_existing_instance( + session: AsyncSession, + instance: ToolInstance, + user: User, + project_id: Any, +) -> dict: + """Restart an instance: re-stage configs, compose restart, tunnel.""" + if instance.tunnel_id: + try: + tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id) + except Exception as exc: + logger.warning("Failed to stop old tunnel: %s", exc) + + if not instance.compose_path or not os.path.exists(instance.compose_path): + instance.status = "error" + await session.commit() + return {"status": instance.status} + + env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs( + session, user.id, instance.tool_type_id, project_id + ) + + stored_profile = None + if instance.selected_profile_id: + stored_profile = await session.get(ConfigProfile, instance.selected_profile_id) + if stored_profile and stored_profile.user_id == user.id: + instance_dir = os.path.dirname(instance.compose_path) + env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile( + stored_profile, instance_dir, env_vars, port_override, start_command, working_directory, extra_volumes + ) + + env_file_path, extra_volumes = await _stage_configs_and_folders( + session, user.id, project_id, os.path.dirname(instance.compose_path), + env_vars, config_files, extra_volumes + ) + + if port_override or start_command or working_directory or extra_volumes: + compose_svc._modify_compose_file( + instance.compose_path, port_override, start_command, working_directory, extra_volumes + ) + + returncode, _stdout, _stderr = compose_svc.execute_compose_command( + instance.compose_path, "restart", env_file=env_file_path + ) + if returncode != 0: + instance.status = "error" + await session.commit() + return {"status": instance.status} + + instance.status = "running" + instance.last_started_at = datetime.now() + + tool_type = await session.get(ToolType, instance.tool_type_id) + await _start_tunnel_if_web(instance, tool_type) + await session.commit() + + return {"status": instance.status, "url": instance.url} + + +async def stop_existing_instance(session: AsyncSession, instance: ToolInstance) -> None: + """Stop an instance and its tunnel.""" + if instance.tunnel_id: + try: + tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id) + except Exception as exc: + logger.warning("Failed to stop tunnel: %s", exc) + + if instance.compose_path and os.path.exists(instance.compose_path): + compose_svc.execute_compose_command(instance.compose_path, "stop") + + instance.status = "stopped" + instance.last_stopped_at = datetime.now() + instance.url = None + instance.public_url = None + instance.tunnel_id = None + await session.commit() + + +async def delete_existing_instance(session: AsyncSession, instance: ToolInstance) -> None: + """Delete an instance, its containers, and its directory.""" + if instance.tunnel_id: + try: + tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id) + except Exception as exc: + logger.warning("Failed to stop tunnel: %s", exc) + + if instance.compose_path and os.path.exists(instance.compose_path): + compose_svc.execute_compose_command(instance.compose_path, "down") + instance_dir = os.path.dirname(instance.compose_path) + if os.path.exists(instance_dir): + shutil.rmtree(instance_dir) + + await session.delete(instance) + await session.commit() + + +# ── Internal helpers ─────────────────────────────────────────────────────── + +async def _build_or_render_compose( + tool_type: ToolType, + instance_name: str, + instance_dir: str, + repo: GitRepository, + user: User, + project_id: Any, + tool_port: int, +) -> str: + """Build Dockerfile or render compose template.""" + if tool_type.definition_type == "dockerfile": + image_tag = f"headquarter/{instance_name}:latest" + if tool_type.dockerfile_template: + returncode, _stdout, stderr = build_image( + instance_dir=instance_dir, + dockerfile=tool_type.dockerfile_template, + tag=image_tag, + build_context=tool_type.build_context, + ) + if returncode != 0: + logger.error("Build failed for %s: %s", instance_name, stderr) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to build Docker image: {stderr[:500]}", + ) + + compose_content = ( + f'version: "3.8"\nservices:\n app:\n' + f' image: {image_tag}\n' + f' container_name: {instance_name}\n' + f' ports:\n - "{tool_port}:{tool_type.default_port}"\n' + f' volumes:\n - {repo.path}:/workspace\n' + f' restart: unless-stopped\n' + ) + else: + variables = { + "REPO_PATH": repo.path, + "INSTANCE_NAME": instance_name, + "INSTANCE_ID": instance_name, + "TOOL_NAME": instance_name, + "TOOL_PORT": tool_port, + "USER_ID": str(user.id), + "PROJECT_ID": str(project_id), + } + compose_content = compose_svc.render_compose_template( + tool_type.compose_template, variables + ) + + compose_svc.write_compose_file(instance_dir, compose_content) + return os.path.join(instance_dir, "docker-compose.yml") + + +async def _fetch_tool_configs( + session: AsyncSession, + user_id: Any, + tool_type_id: Any, + project_id: Any, +) -> tuple[dict, dict, Any, Any, Any, dict, list]: + """Fetch tool configs and return parsed values.""" + env_vars: dict[str, str] = {} + config_files: dict[str, str] = {} + port_override = None + start_command = None + working_directory = None + extra_env_vars: dict[str, str] = {} + extra_volumes: list[dict] = [] + + query = ( + select(ToolConfig) + .where(ToolConfig.user_id == user_id, ToolConfig.tool_type_id == tool_type_id) + .where((ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None))) + ) + configs = (await session.execute(query)).scalars().all() + + for cfg in configs: + if cfg.config_type == "env": + env_vars[cfg.key] = cfg.value + elif cfg.config_type == "file" and cfg.file_path: + config_files[cfg.file_path] = cfg.value + if cfg.port_override: + port_override = cfg.port_override + if cfg.start_command: + start_command = cfg.start_command + if cfg.working_directory: + working_directory = cfg.working_directory + if cfg.environment_variables: + extra_env_vars.update(cfg.environment_variables) + if cfg.volumes: + extra_volumes.extend(cfg.volumes) + + env_vars.update(extra_env_vars) + return env_vars, config_files, port_override, start_command, working_directory, extra_env_vars, extra_volumes + + +async def _stage_configs_and_folders( + session: AsyncSession, + user_id: Any, + project_id: Any, + instance_dir: str, + env_vars: dict[str, str], + config_files: dict[str, str], + extra_volumes: list[dict], +) -> tuple[str | None, list[dict]]: + """Write env/config files and config folders.""" + env_file_path: str | None = None + if env_vars: + env_file_path = compose_svc.write_env_file(instance_dir, env_vars) + if config_files: + config_staging.write_config_files(instance_dir, config_files) + + folder_query = select(ConfigFolder).where( + ConfigFolder.user_id == user_id, ConfigFolder.is_active.is_(True) + ) + folders = (await session.execute(folder_query)).scalars().all() + if folders: + folder_volumes = config_staging.write_config_folder_files( + instance_dir, folders, str(project_id) + ) + extra_volumes.extend(folder_volumes) + + return env_file_path, extra_volumes + + +async def _start_tunnel_if_web(instance: ToolInstance, tool_type: ToolType) -> None: + """Create Cloudflare tunnel for web-enabled tools.""" + if "web" not in tool_type.interfaces or not tool_type.default_port: + instance.url = None + instance.public_url = None + return + + try: + tunnel_info = tunnel_svc.start_cloudflared_tunnel( + container_name=instance.container_name or instance.name, + port=tool_type.default_port, + ) + instance.tunnel_id = tunnel_info["pid"] + instance.public_url = tunnel_info["url"] + instance.url = tunnel_info["url"] + logger.info("Created tunnel for instance %s: %s", instance.id, tunnel_info["url"]) + except Exception as exc: + logger.error("Failed to create tunnel for instance %s: %s", instance.id, exc) + instance.status = "error" + instance.url = None + + +async def _run_readiness_probe( + instance: ToolInstance, tool_type: ToolType +) -> tuple[bool, list[str]]: + """Run readiness probe if configured.""" + if not tool_type.readiness_probe or not instance.container_id: + return True, [] + + probe = tool_type.readiness_probe + command = probe.get("command", "") + if not command: + return True, [] + + return await execute_probe( + container_id=instance.container_id, + command=command, + timeout=probe.get("timeout", 30), + interval=probe.get("interval", 2), + ) diff --git a/apps/web/src/components/commit-dialog.tsx b/apps/web/src/components/commit-dialog.tsx index 491142f..fd80688 100644 --- a/apps/web/src/components/commit-dialog.tsx +++ b/apps/web/src/components/commit-dialog.tsx @@ -1,3 +1,4 @@ +import styles from "./features/git/CommitDialog.module.css"; import React, { useState } from "react"; import { Icon } from "./icon"; @@ -71,40 +72,40 @@ export const CommitDialog: React.FC = ({ const hasChanges = diff.some((d) => d.type !== "same"); return ( -
-
-
+
+
+

Commit Changes

-
-
-

+

+

Editing: {filePath}

{!hasChanges && ( -
No changes to commit
+
No changes to commit
)} {hasChanges && ( -
+

Changes

-
+
{diff.map((line, i) => (
- {line.lineNum} - + {line.lineNum} + {line.type === "added" && "+"} {line.type === "removed" && "-"} {line.type === "same" && " "} - {line.line} + {line.line}
))}
@@ -125,7 +126,7 @@ export const CommitDialog: React.FC = ({ {error &&
{error}
}
-
+
-
+
{mode === "view" && ( - {error &&
{error}
} +
+ {error &&
{error}
} -
-
+
+
-
+
{showNewBranch && ( -
+
setNewBranchName(e.target.value)} - className="toolbar-input" + className={styles.toolbarInput} />