diff --git a/apps/api/src/schemas/config/config_profile.py b/apps/api/src/schemas/config/config_profile.py new file mode 100644 index 0000000..7bd1aba --- /dev/null +++ b/apps/api/src/schemas/config/config_profile.py @@ -0,0 +1,281 @@ +"""Config profile request/response schemas.""" + +import uuid +from typing import Any + +from pydantic import BaseModel, Field, field_validator, model_validator + +from src.api.shared_validators import validate_env_vars as _validate_env_vars + + +def _validate_uuid(v: str | None) -> str | None: + if v is None: + return v + try: + uuid.UUID(v) + except ValueError: + raise ValueError(f"Invalid UUID: {v}") + return v + + +class GitMountMapping(BaseModel): + source_path: str = Field( + description="Path within repository (supports glob patterns)" + ) + target_path: str = Field(description="Absolute path inside container") + + @field_validator("source_path") + @classmethod + def validate_source_path(cls, v: str) -> str: + if v.startswith("/"): + raise ValueError("source_path must be relative (no leading /)") + if ".." in v: + raise ValueError("source_path cannot contain path traversal (..)") + return v + + @field_validator("target_path") + @classmethod + def validate_target_path(cls, v: str) -> str: + if ".." in v: + raise ValueError("target_path cannot contain path traversal (..)") + return v + + +class GitMountItem(BaseModel): + remote_url: str = Field(description="Git remote URL (HTTPS or SSH)") + source_path: str | None = Field( + default=None, description="Path within repository (legacy single mapping)" + ) + target_path: str | None = Field( + default=None, + description="Absolute path inside container (legacy single mapping)", + ) + branch: str | None = Field(default=None, description="Optional branch or tag name") + mappings: list[GitMountMapping] | None = Field( + default=None, description="Multiple source/target mappings from the same repo" + ) + + @field_validator("remote_url") + @classmethod + def validate_remote_url(cls, v: str) -> str: + if not v.startswith(("http://", "https://", "git@", "ssh://")): + raise ValueError( + "remote_url must be a valid git URL (https://, git@, or ssh://)" + ) + return v + + @field_validator("source_path") + @classmethod + def validate_source_path(cls, v: str | None) -> str | None: + if v is None: + return v + if v.startswith("/"): + raise ValueError("source_path must be relative (no leading /)") + if ".." in v: + raise ValueError("source_path cannot contain path traversal (..)") + return v + + @field_validator("target_path") + @classmethod + def validate_target_path(cls, v: str | None) -> str | None: + if v is None: + return v + if ".." in v: + raise ValueError("target_path cannot contain path traversal (..)") + return v + + @model_validator(mode="after") + def check_mappings_or_legacy(self): + has_legacy = self.source_path is not None and self.target_path is not None + has_mappings = self.mappings is not None and len(self.mappings) > 0 + if not has_legacy and not has_mappings: + raise ValueError( + "Git mount must have either 'mappings' (non-empty array) or both 'source_path' and 'target_path'" + ) + return self + + +class MountItem(BaseModel): + target: str = Field(description="Absolute mount target path") + mode: str = Field(default="rw", description="Mount mode: ro or rw") + files: dict = Field( + default_factory=dict, description="Files as {relative_path: content}" + ) + + @field_validator("target") + @classmethod + def validate_target(cls, v: str) -> str: + if not v.startswith("/"): + raise ValueError("Mount target must be absolute (start with /)") + return v + + @field_validator("mode") + @classmethod + def validate_mode(cls, v: str) -> str: + if v not in ("ro", "rw"): + raise ValueError("Mount mode must be 'ro' or 'rw'") + return v + + @field_validator("files") + @classmethod + def validate_files(cls, v: dict) -> dict: + for path in v: + if ".." in path or not path: + raise ValueError(f"Invalid file path: {path}") + if path.startswith("/"): + raise ValueError( + f"Mount file paths must be relative (got: {path}). " + f"The mount target defines the absolute container path." + ) + return v + + +class ConfigProfileCreate(BaseModel): + name: str = Field(description="Profile name (unique per user)") + description: str | None = Field(default=None, description="Optional description") + project_id: str | None = Field(default=None, description="Optional project ID") + tool_type_id: str | None = Field(default=None, description="Optional tool type ID") + env_vars: dict = Field(default_factory=dict, description="Environment variables") + runtime_hints: dict = Field(default_factory=dict, description="Runtime hints") + mounts: list[MountItem] = Field( + default_factory=list, description="Mount definitions" + ) + files: dict = Field( + default_factory=dict, description="Files as {relative_path: content}" + ) + git_mounts: list[GitMountItem] = Field( + default_factory=list, description="Git repository mounts" + ) + is_default: bool = Field( + default=False, description="Whether this is the default profile for its scope" + ) + + @field_validator("project_id", "tool_type_id") + @classmethod + def validate_uuids(cls, v: str | None) -> str | None: + return _validate_uuid(v) + + @field_validator("files") + @classmethod + def validate_files(cls, v: dict) -> dict: + for path in v: + if ".." in path or not path: + raise ValueError(f"Invalid file path: {path}") + if path.startswith("/"): + raise ValueError( + f"File paths must be relative (got: {path}). " + f"Use Mounts for absolute container paths." + ) + return v + + @field_validator("env_vars") + @classmethod + def validate_env_vars(cls, v: dict) -> dict: + result = _validate_env_vars(v) + if result is None: + raise ValueError("env_vars must be a JSON object") + return result + + @field_validator("runtime_hints") + @classmethod + def validate_runtime_hints(cls, v: dict) -> dict: + if not isinstance(v, dict): + raise ValueError("runtime_hints must be a JSON object") + return v + + @field_validator("mounts") + @classmethod + def validate_mounts(cls, v: list) -> list: + if not isinstance(v, list): + raise ValueError("mounts must be a JSON array") + return v + + +class ConfigProfileUpdate(BaseModel): + name: str | None = Field(default=None, description="Profile name") + description: str | None = Field(default=None, description="Optional description") + project_id: str | None = Field(default=None, description="Optional project ID") + tool_type_id: str | None = Field(default=None, description="Optional tool type ID") + env_vars: dict | None = Field(default=None, description="Environment variables") + runtime_hints: dict | None = Field(default=None, description="Runtime hints") + mounts: list[MountItem] | None = Field( + default=None, description="Mount definitions" + ) + files: dict | None = Field( + default=None, description="Files as {relative_path: content}" + ) + git_mounts: list[GitMountItem] | None = Field( + default=None, description="Git repository mounts" + ) + is_default: bool | None = Field( + default=None, description="Whether this is the default profile" + ) + + @field_validator("project_id", "tool_type_id") + @classmethod + def validate_uuids(cls, v: str | None) -> str | None: + return _validate_uuid(v) + + @field_validator("files") + @classmethod + def validate_files(cls, v: dict | None) -> dict | None: + if v is None: + return v + for path in v: + if ".." in path or path.startswith("/") or not path: + raise ValueError(f"Invalid file path: {path}") + return v + + +class ConfigProfileIncludeUpdate(BaseModel): + includes: list[str] = Field(description="Ordered list of included profile IDs") + + @field_validator("includes") + @classmethod + def validate_includes(cls, v: list) -> list: + for item in v: + try: + uuid.UUID(item) + except ValueError: + raise ValueError(f"Invalid UUID in includes: {item}") + return v + + +class ConfigProfileResponse(BaseModel): + id: str + user_id: str + name: str + description: str | None + project_id: str | None + tool_type_id: str | None + env_vars: dict + runtime_hints: dict + mounts: list + files: dict + git_mounts: list + is_default: bool + includes: list[dict] + created_at: str + updated_at: str + + +class DefaultProfilesUpdate(BaseModel): + default_profiles: dict[str, str] = Field( + description="Mapping of tool_type_id -> profile_id for default profiles" + ) + + +class ValidateGitUrlRequest(BaseModel): + url: str = Field(description="Git remote URL to validate") + ssh_key_id: str | None = Field( + default=None, description="Optional SSH key ID for private repos" + ) + + +class ValidateGitUrlResponse(BaseModel): + valid: bool + suggested_url: str | None = None + branches: list[str] | None = None + default_branch: str | None = None + error: str | None = None + error_code: str | None = None diff --git a/apps/api/src/schemas/project/git_repository.py b/apps/api/src/schemas/project/git_repository.py new file mode 100644 index 0000000..7d4235c --- /dev/null +++ b/apps/api/src/schemas/project/git_repository.py @@ -0,0 +1,47 @@ +"""Git repository request/response schemas.""" + +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class GitRepositoryCreate(BaseModel): + name: str + remote_url: str | None = None + force_original_url: bool = False + ssh_key_id: str | None = None + + +class URLParseRequest(BaseModel): + url: str + + +class URLParseResponse(BaseModel): + original_url: str + base_url: str | None + is_valid_clone_url: bool + needs_parsing: bool + host: str | None + message: str + error_code: str | None + + +class GitRepositoryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + path: str + project_id: uuid.UUID | None + owner_id: uuid.UUID + is_mirror: bool + remote_url: str | None + last_push: datetime | None + ssh_key_id: uuid.UUID | None + created_at: datetime + updated_at: datetime + + +class UpdateSSHKeyRequest(BaseModel): + ssh_key_id: str | None = None diff --git a/apps/api/src/schemas/project/project.py b/apps/api/src/schemas/project/project.py new file mode 100644 index 0000000..ffd6758 --- /dev/null +++ b/apps/api/src/schemas/project/project.py @@ -0,0 +1,29 @@ +"""Project request/response schemas.""" + +import uuid + +from pydantic import BaseModel, ConfigDict + + +class ProjectCreate(BaseModel): + name: str + description: str | None = None + + +class ProjectUpdate(BaseModel): + name: str | None = None + description: str | None = None + + +class ProjectResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + description: str | None + owner_id: uuid.UUID + default_ssh_key_id: uuid.UUID | None + + +class SetDefaultSSHKeyRequest(BaseModel): + ssh_key_id: uuid.UUID diff --git a/apps/api/src/schemas/project/ssh_key.py b/apps/api/src/schemas/project/ssh_key.py new file mode 100644 index 0000000..188eaf3 --- /dev/null +++ b/apps/api/src/schemas/project/ssh_key.py @@ -0,0 +1,36 @@ +"""SSH key request/response schemas.""" + +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class SSHKeyCreate(BaseModel): + name: str + + +class SSHKeyResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + public_key: str + created_at: datetime + + +class SignPayloadRequest(BaseModel): + payload: str + + +class SignatureResponse(BaseModel): + signature: str + + +class VerifySignatureRequest(BaseModel): + payload: str + signature: str + + +class VerifySignatureResponse(BaseModel): + valid: bool diff --git a/apps/api/src/schemas/system/health.py b/apps/api/src/schemas/system/health.py new file mode 100644 index 0000000..4aab904 --- /dev/null +++ b/apps/api/src/schemas/system/health.py @@ -0,0 +1,50 @@ +"""Health check response schemas.""" + +from pydantic import BaseModel, Field + + +class DatabaseHealth(BaseModel): + """Database health check result.""" + + status: str = Field(description="Database health status", examples=["healthy"]) + response_time_ms: float = Field( + description="Query response time in milliseconds", examples=[5.2] + ) + + +class DiskHealth(BaseModel): + """Disk space health check result.""" + + status: str = Field(description="Disk health status", examples=["healthy"]) + free_gb: float = Field(description="Free disk space in GB", examples=[45.2]) + total_gb: float = Field(description="Total disk space in GB", examples=[100.0]) + + +class HealthChecks(BaseModel): + """Individual health checks.""" + + database: DatabaseHealth | None = None + disk: DiskHealth | None = None + + +class HealthResponse(BaseModel): + """Overall health check response.""" + + status: str = Field(description="Overall health status", examples=["healthy"]) + timestamp: str = Field( + description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"] + ) + version: str = Field(description="API version", examples=["0.1.0"]) + checks: HealthChecks = Field(description="Individual health checks") + uptime_seconds: float = Field( + description="Server uptime in seconds", examples=[3600.0] + ) + + +class DatabaseHealthResponse(BaseModel): + """Database-specific health check response.""" + + status: str = Field(description="Database health status", examples=["healthy"]) + response_time_ms: float = Field( + description="Query response time in milliseconds", examples=[5.2] + ) diff --git a/apps/api/src/schemas/tool/tool_instance.py b/apps/api/src/schemas/tool/tool_instance.py new file mode 100644 index 0000000..72af055 --- /dev/null +++ b/apps/api/src/schemas/tool/tool_instance.py @@ -0,0 +1,45 @@ +"""Tool instance request/response schemas.""" + +from pydantic import BaseModel, Field + + +class CreateInstanceRequest(BaseModel): + """Request body for creating a tool instance.""" + + 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" + ) + workspace_id: str | None = Field( + default=None, description="UUID of workspace to mount (replaces clone_mode)" + ) + clone_mode: str = Field( + default="mount", description="Repository access mode: 'mount' or 'clone'" + ) + branch: str | None = Field( + default="main", description="Branch to clone (when clone_mode='clone')" + ) + new_branch: str | None = Field( + default=None, description="Create a new local branch after cloning" + ) + config_profile_id: str | None = Field( + default=None, description="Optional config profile ID for launch" + ) + ssh_key_ids: list[str] = Field( + default_factory=list, description="SSH key IDs to mount into container ~/.ssh" + ) + + +class StartInstanceRequest(BaseModel): + """Request body for starting a tool instance.""" + + model_config = {"extra": "ignore"} + + config_profile_id: str | None = Field( + default=None, description="Config profile ID to apply, or null for none" + ) + ssh_key_ids: list[str] = Field( + default_factory=list, description="SSH key IDs to mount into container ~/.ssh" + ) diff --git a/apps/api/src/schemas/tool/tool_type.py b/apps/api/src/schemas/tool/tool_type.py new file mode 100644 index 0000000..a80b272 --- /dev/null +++ b/apps/api/src/schemas/tool/tool_type.py @@ -0,0 +1,198 @@ +"""Tool type request/response schemas.""" + +import uuid + +from pydantic import BaseModel, field_validator, model_validator + +from src.api.tool_types_validation import ( + check_port_exposed, + validate_compose_yaml, + validate_required_variables, +) + + +class ToolTypeCreate(BaseModel): + name: str + display_name: str + description: str | None = None + default_port: int = 0 + definition_type: str = "compose" + manifest_id: uuid.UUID | None = None + compose_template: str | None = None + dockerfile_template: str | None = None + build_context: dict | None = None + readiness_probe: dict | None = None + startup_command: str | None = None + required_variables: list[str] = [] + category: str = "other" + interface_type: str = "web" + requires_port: bool = True + + @field_validator("definition_type") + @classmethod + def validate_definition_type(cls, v: str) -> str: + if v not in ("compose", "dockerfile", "manifest"): + raise ValueError( + "definition_type must be 'compose', 'dockerfile', or 'manifest'" + ) + return v + + @field_validator("compose_template") + @classmethod + def validate_compose_template(cls, v: str | None, info) -> str | None: + data = info.data + if data.get("definition_type") != "compose": + return v + + if v is None or not v.strip(): + raise ValueError( + "compose_template is required when definition_type is 'compose'" + ) + + validate_compose_yaml(v) + return v + + @field_validator("dockerfile_template") + @classmethod + def validate_dockerfile_template(cls, v: str | None, info) -> str | None: + data = info.data + if data.get("definition_type") != "dockerfile": + return v + + if v is None or not v.strip(): + raise ValueError( + "dockerfile_template is required when definition_type is 'dockerfile'" + ) + + if not v.strip().startswith("FROM"): + raise ValueError("Dockerfile must start with a FROM instruction") + + return v + + @field_validator("interface_type") + @classmethod + def validate_interface_type(cls, v: str) -> str: + if v not in ("web", "terminal"): + raise ValueError("interface_type must be 'web' or 'terminal'") + return v + + @field_validator("default_port") + @classmethod + def validate_default_port(cls, v: int, info) -> int: + data = info.data + requires_port = data.get("requires_port", True) + if not requires_port: + return v + if v <= 0 or v > 65535: + raise ValueError("Port must be between 1 and 65535") + return v + + @field_validator("required_variables") + @classmethod + def validate_required_variables(cls, v: list[str], info) -> list[str]: + if not v: + return v + + data = info.data + if data.get("definition_type") != "compose": + return v + + template = data.get("compose_template") + if not template: + return v + + validate_required_variables(template, v) + return v + + @model_validator(mode="after") + def validate_templates(self) -> "ToolTypeCreate": + if self.definition_type == "manifest": + if self.manifest_id is None: + raise ValueError( + "manifest_id is required when definition_type is 'manifest'" + ) + return self + + if self.definition_type == "dockerfile" and ( + self.dockerfile_template is None or not self.dockerfile_template.strip() + ): + raise ValueError( + "dockerfile_template is required when definition_type is 'dockerfile'" + ) + if self.definition_type == "compose" and ( + self.compose_template is None or not self.compose_template.strip() + ): + raise ValueError( + "compose_template is required when definition_type is 'compose'" + ) + + if ( + self.requires_port + and self.definition_type == "compose" + and self.compose_template + ): + try: + parsed = validate_compose_yaml(self.compose_template) + except ValueError: + return self + + if not check_port_exposed(parsed, self.default_port): + raise ValueError( + f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section." + ) + + return self + + +class ToolTypeUpdate(BaseModel): + display_name: str | None = None + description: str | None = None + default_port: int | None = None + definition_type: str | None = None + manifest_id: uuid.UUID | None = None + compose_template: str | None = None + dockerfile_template: str | None = None + build_context: dict | None = None + readiness_probe: dict | None = None + startup_command: str | None = None + required_variables: list[str] | None = None + category: str | None = None + interface_type: str | None = None + requires_port: bool | None = None + + @field_validator("definition_type") + @classmethod + def validate_definition_type(cls, v: str | None) -> str | None: + if v is None: + return v + if v not in ("compose", "dockerfile", "manifest"): + raise ValueError( + "definition_type must be 'compose', 'dockerfile', or 'manifest'" + ) + return v + + @field_validator("interface_type") + @classmethod + def validate_interface_type(cls, v: str | None) -> str | None: + if v is None: + return v + if v not in ("web", "terminal"): + raise ValueError("interface_type must be 'web' or 'terminal'") + return v + + @field_validator("compose_template") + @classmethod + def validate_compose_template(cls, v: str | None, info) -> str | None: + if v is None: + return v + validate_compose_yaml(v) + return v + + @field_validator("dockerfile_template") + @classmethod + def validate_dockerfile_template(cls, v: str | None, info) -> str | None: + if v is None: + return v + if not v.strip().startswith("FROM"): + raise ValueError("Dockerfile must start with a FROM instruction") + return v diff --git a/apps/api/src/schemas/user/user.py b/apps/api/src/schemas/user/user.py new file mode 100644 index 0000000..8fbec85 --- /dev/null +++ b/apps/api/src/schemas/user/user.py @@ -0,0 +1,19 @@ +"""User response schemas.""" + +import uuid + +from pydantic import BaseModel, ConfigDict + + +class UserProfileResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + email: str + name: str + avatar_url: str | None + + +class UserProfileUpdate(BaseModel): + name: str | None = None + email: str | None = None diff --git a/apps/api/src/schemas/user/user_config.py b/apps/api/src/schemas/user/user_config.py new file mode 100644 index 0000000..517cee7 --- /dev/null +++ b/apps/api/src/schemas/user/user_config.py @@ -0,0 +1,25 @@ +"""User config response schemas.""" + +from pydantic import BaseModel, ConfigDict + + +class UserConfigResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + default_editor: str | None = None + theme: str = "system" + git_user_name: str | None = None + git_user_email: str | None = None + last_session_id: str | None = None + notification_mute_categories: list[str] | None = None + notification_toast_level: str | None = None + + +class UserConfigUpdate(BaseModel): + default_editor: str | None = None + theme: str | None = None + git_user_name: str | None = None + git_user_email: str | None = None + last_session_id: str | None = None + notification_mute_categories: list[str] | None = None + notification_toast_level: str | None = None diff --git a/apps/api/src/services/docker/__init__.py b/apps/api/src/services/docker/__init__.py new file mode 100644 index 0000000..e9b7661 --- /dev/null +++ b/apps/api/src/services/docker/__init__.py @@ -0,0 +1,55 @@ +"""Docker services package for container and compose operations.""" + +from src.services.docker.compose import ( + execute_compose_command, + render_compose_template, + sort_volumes_by_specificity, + write_compose_file, +) +from src.services.docker.config_staging import ( + ensure_instance_directory, + write_config_files, + write_env_file, +) +from src.services.docker.container import ( + connect_container_to_network, + find_free_port, + get_backend_network_name, + get_container_id, + get_container_ip_on_network, + get_container_logs, + get_container_name, + get_container_status, + is_container_on_network, + wait_for_container_running, +) +from src.services.docker.tunnel import ( + check_tunnel_health, + recreate_tunnel, + start_tunnel, + stop_tunnel, +) + +__all__ = [ + "check_tunnel_health", + "connect_container_to_network", + "ensure_instance_directory", + "execute_compose_command", + "find_free_port", + "get_backend_network_name", + "get_container_id", + "get_container_ip_on_network", + "get_container_logs", + "get_container_name", + "get_container_status", + "is_container_on_network", + "recreate_tunnel", + "render_compose_template", + "sort_volumes_by_specificity", + "start_tunnel", + "stop_tunnel", + "wait_for_container_running", + "write_compose_file", + "write_config_files", + "write_env_file", +] diff --git a/apps/api/src/services/docker/compose.py b/apps/api/src/services/docker/compose.py new file mode 100644 index 0000000..660b2b0 --- /dev/null +++ b/apps/api/src/services/docker/compose.py @@ -0,0 +1,120 @@ +"""Docker Compose file generation and manipulation.""" + +import logging +import subprocess +from collections import Counter +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +def sort_volumes_by_specificity(volumes: list[str]) -> list[str]: + """Sort volume strings so parent paths come before child paths. + + Docker Compose mounts volumes in array order. A later mount at a parent + path hides earlier mounts at child paths. By sorting shallow paths first + and deep paths last, deeper (more specific) mounts overlay correctly. + + Volume format: source:target or source:target:type + + Args: + volumes: List of Docker volume mount strings. + + Returns: + Sorted list with parent paths before child paths. + """ + + def _target_depth(vol: str) -> int: + parts = vol.split(":") + if len(parts) < 2: + return 0 + target = parts[1].rstrip("/") + if not target or target == "/": + return 0 + return target.count("/") + + # Detect duplicate targets and warn + targets = [] + for vol in volumes: + parts = vol.split(":") + targets.append(parts[1] if len(parts) > 1 else "") + dupes = [t for t, c in Counter(targets).items() if c > 1] + if dupes: + logger.warning("Duplicate mount targets detected: %s", dupes) + + # Stable sort: parent paths first, child paths last + return sorted(volumes, key=_target_depth) + + +def render_compose_template(template: str, variables: dict[str, Any]) -> str: + """Render a Docker Compose template with variable substitution. + + Args: + template: The compose template string + variables: Dictionary of variable names to values + + Returns: + Rendered compose file content + """ + result = template + for key, value in variables.items(): + placeholder = f"{{{{{key}}}}}" + result = result.replace(placeholder, str(value)) + return result + + +def write_compose_file(instance_dir: str, content: str) -> str: + """Write the rendered compose file to the instance directory. + + Args: + instance_dir: Path to instance directory + content: Rendered compose content + + Returns: + Path to the compose file + """ + compose_path = Path(instance_dir) / "docker-compose.yml" + compose_path.write_text(content) + return str(compose_path) + + +def execute_compose_command( + compose_path: str, action: str, timeout: int = 60, env_file: str | None = None +) -> tuple[int, str, str]: + """Execute a docker compose command. + + Args: + compose_path: Path to docker-compose.yml + action: The compose action (up, down, start, stop, restart) + timeout: Command timeout in seconds + env_file: Optional path to .env file for environment variables + + Returns: + Tuple of (returncode, stdout, stderr) + """ + instance_dir = Path(compose_path).parent + + cmd = ["docker", "compose", "-f", compose_path] + + if env_file: + cmd.extend(["--env-file", env_file]) + + if action == "up": + cmd.extend(["up", "-d", "--force-recreate"]) + elif action == "down": + cmd.extend(["down", "-v"]) + elif action in ("start", "stop", "restart"): + cmd.append(action) + else: + raise ValueError(f"Unknown compose action: {action}") + + result = subprocess.run( + cmd, + cwd=str(instance_dir), + capture_output=True, + text=True, + timeout=timeout, + ) + + return result.returncode, result.stdout, result.stderr diff --git a/apps/api/src/services/docker/config_staging.py b/apps/api/src/services/docker/config_staging.py new file mode 100644 index 0000000..b69329e --- /dev/null +++ b/apps/api/src/services/docker/config_staging.py @@ -0,0 +1,61 @@ +"""Staging configuration files into instance directories.""" + +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str: + """Create and return the instance directory path. + + Args: + instance_id: Unique instance identifier + base_path: Base directory for all instances (defaults to Settings.instance_base_path) + + Returns: + Absolute path to instance directory + """ + if base_path is None: + from src.config import Settings + + base_path = Settings().instance_base_path + instance_dir = Path(base_path) / instance_id + instance_dir.mkdir(parents=True, exist_ok=True) + return str(instance_dir.absolute()) + + +def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str: + """Write environment variables to a .env file. + + Args: + instance_dir: Path to instance directory + env_vars: Dictionary of env var names to values + + Returns: + Path to the env file + """ + env_path = Path(instance_dir) / ".env" + lines = [f'{key}="{value}"' for key, value in env_vars.items()] + env_path.write_text("\n".join(lines) + "\n") + return str(env_path) + + +def write_config_files(instance_dir: str, files: dict[str, str]) -> None: + """Write config files to the instance directory. + + Args: + instance_dir: Path to instance directory + files: Dictionary of file paths (relative to instance dir) to content + """ + instance_path = Path(instance_dir) + for file_path, content in files.items(): + # Ensure the path is within the instance directory (security) + full_path = instance_path / file_path + try: + full_path.resolve().relative_to(instance_path.resolve()) + except ValueError: + raise ValueError(f"File path '{file_path}' escapes instance directory") + + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(content) diff --git a/apps/api/src/services/docker.py b/apps/api/src/services/docker/container.py similarity index 63% rename from apps/api/src/services/docker.py rename to apps/api/src/services/docker/container.py index 5e5a606..ea1db4f 100644 --- a/apps/api/src/services/docker.py +++ b/apps/api/src/services/docker/container.py @@ -1,181 +1,13 @@ -"""Docker service for managing tool instances.""" +"""Docker container runtime queries and network management.""" import logging import subprocess import time -from collections import Counter -from pathlib import Path from typing import Any logger = logging.getLogger(__name__) -def sort_volumes_by_specificity(volumes: list[str]) -> list[str]: - """Sort volume strings so parent paths come before child paths. - - Docker Compose mounts volumes in array order. A later mount at a parent - path hides earlier mounts at child paths. By sorting shallow paths first - and deep paths last, deeper (more specific) mounts overlay correctly. - - Volume format: source:target or source:target:type - - Args: - volumes: List of Docker volume mount strings. - - Returns: - Sorted list with parent paths before child paths. - """ - - def _target_depth(vol: str) -> int: - parts = vol.split(":") - if len(parts) < 2: - return 0 - target = parts[1].rstrip("/") - if not target or target == "/": - return 0 - return target.count("/") - - # Detect duplicate targets and warn - targets = [] - for vol in volumes: - parts = vol.split(":") - targets.append(parts[1] if len(parts) > 1 else "") - dupes = [t for t, c in Counter(targets).items() if c > 1] - if dupes: - logger.warning("Duplicate mount targets detected: %s", dupes) - - # Stable sort: parent paths first, child paths last - return sorted(volumes, key=_target_depth) - - -def render_compose_template(template: str, variables: dict[str, Any]) -> str: - """Render a Docker Compose template with variable substitution. - - Args: - template: The compose template string - variables: Dictionary of variable names to values - - Returns: - Rendered compose file content - """ - result = template - for key, value in variables.items(): - placeholder = f"{{{{{key}}}}}" - result = result.replace(placeholder, str(value)) - return result - - -def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str: - """Create and return the instance directory path. - - Args: - instance_id: Unique instance identifier - base_path: Base directory for all instances (defaults to Settings.instance_base_path) - - Returns: - Absolute path to instance directory - """ - if base_path is None: - from src.config import Settings - - base_path = Settings().instance_base_path - instance_dir = Path(base_path) / instance_id - instance_dir.mkdir(parents=True, exist_ok=True) - return str(instance_dir.absolute()) - - -def write_compose_file(instance_dir: str, content: str) -> str: - """Write the rendered compose file to the instance directory. - - Args: - instance_dir: Path to instance directory - content: Rendered compose content - - Returns: - Path to the compose file - """ - compose_path = Path(instance_dir) / "docker-compose.yml" - compose_path.write_text(content) - return str(compose_path) - - -def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str: - """Write environment variables to a .env file. - - Args: - instance_dir: Path to instance directory - env_vars: Dictionary of env var names to values - - Returns: - Path to the env file - """ - env_path = Path(instance_dir) / ".env" - lines = [f'{key}="{value}"' for key, value in env_vars.items()] - env_path.write_text("\n".join(lines) + "\n") - return str(env_path) - - -def write_config_files(instance_dir: str, files: dict[str, str]) -> None: - """Write config files to the instance directory. - - Args: - instance_dir: Path to instance directory - files: Dictionary of file paths (relative to instance dir) to content - """ - instance_path = Path(instance_dir) - for file_path, content in files.items(): - # Ensure the path is within the instance directory (security) - full_path = instance_path / file_path - try: - full_path.resolve().relative_to(instance_path.resolve()) - except ValueError: - raise ValueError(f"File path '{file_path}' escapes instance directory") - - full_path.parent.mkdir(parents=True, exist_ok=True) - full_path.write_text(content) - - -def execute_compose_command( - compose_path: str, action: str, timeout: int = 60, env_file: str | None = None -) -> tuple[int, str, str]: - """Execute a docker compose command. - - Args: - compose_path: Path to docker-compose.yml - action: The compose action (up, down, start, stop, restart) - timeout: Command timeout in seconds - env_file: Optional path to .env file for environment variables - - Returns: - Tuple of (returncode, stdout, stderr) - """ - instance_dir = Path(compose_path).parent - - cmd = ["docker", "compose", "-f", compose_path] - - if env_file: - cmd.extend(["--env-file", env_file]) - - if action == "up": - cmd.extend(["up", "-d", "--force-recreate"]) - elif action == "down": - cmd.extend(["down", "-v"]) - elif action in ("start", "stop", "restart"): - cmd.append(action) - else: - raise ValueError(f"Unknown compose action: {action}") - - result = subprocess.run( - cmd, - cwd=str(instance_dir), - capture_output=True, - text=True, - timeout=timeout, - ) - - return result.returncode, result.stdout, result.stderr - - def get_container_id(instance_name: str) -> str | None: """Get the container ID for a compose service. diff --git a/apps/api/src/services/docker/tunnel.py b/apps/api/src/services/docker/tunnel.py new file mode 100644 index 0000000..980c674 --- /dev/null +++ b/apps/api/src/services/docker/tunnel.py @@ -0,0 +1,281 @@ +"""Cloudflare tunnel management using cloudflared Docker containers. + +Each tunnel runs as a Docker container on the same 'backend' network as the API. +cloudflared connects to the tool container by its Docker Compose service name +(e.g. http://code-server-headquarter-34837cd3:8443). +""" + +import logging +import re +import subprocess +from typing import Any + +from src.services.docker.container import get_backend_network_name + +logger = logging.getLogger(__name__) + +TUNNEL_IMAGE = "cloudflare/cloudflared:latest" + + +def _tunnel_container_name(instance_name: str) -> str: + return f"tunnel-{instance_name.lower()}" + + +def _ensure_image() -> None: + """Pull cloudflared image if not already present.""" + result = subprocess.run( + ["docker", "images", "-q", TUNNEL_IMAGE], + capture_output=True, + text=True, + ) + if not result.stdout.strip(): + logger.info("Pulling %s ...", TUNNEL_IMAGE) + pull = subprocess.run( + ["docker", "pull", TUNNEL_IMAGE], + capture_output=True, + text=True, + ) + if pull.returncode != 0: + logger.warning("Failed to pull %s: %s", TUNNEL_IMAGE, pull.stderr) + + +def _cleanup_stale_tunnel(tunnel_name: str) -> None: + """Remove any existing tunnel container with this name.""" + subprocess.run( + ["docker", "stop", "-t", "3", tunnel_name], + capture_output=True, + text=True, + ) + subprocess.run( + ["docker", "rm", "-f", tunnel_name], + capture_output=True, + text=True, + ) + + +def _get_tunnel_logs(tunnel_name: str) -> tuple[str, str]: + """Get stdout and stderr logs from a container.""" + result = subprocess.run( + ["docker", "logs", tunnel_name], + capture_output=True, + text=True, + ) + return result.stdout, result.stderr + + +def _get_tunnel_exit_code(tunnel_name: str) -> int | None: + """Get exit code of a container if it has exited.""" + result = subprocess.run( + ["docker", "inspect", "-f", "{{.State.ExitCode}}", tunnel_name], + capture_output=True, + text=True, + ) + if result.returncode == 0: + try: + return int(result.stdout.strip()) + except ValueError: + pass + return None + + +def start_tunnel( + instance_name: str, + container_port: int, + timeout: int = 30, + target_url: str | None = None, +) -> dict[str, str]: + """Start a temporary Cloudflare tunnel for an instance. + + Args: + instance_name: The tool instance name (used for tunnel naming). + container_port: The port the tool container listens on internally. + timeout: Seconds to wait for the tunnel URL. + target_url: Optional explicit URL to proxy to. If omitted, derives + http://{instance_name.lower()}:{container_port}. + + Returns: + Dict with 'url' and 'container_name'. + """ + _ensure_image() + + tunnel_name = _tunnel_container_name(instance_name) + _cleanup_stale_tunnel(tunnel_name) + + # Target the tool container by name on the backend network + if target_url is None: + target_url = f"http://{instance_name.lower()}:{container_port}" + + cmd = [ + "docker", + "run", + "-d", + "--network", + get_backend_network_name(), + "--name", + tunnel_name, + TUNNEL_IMAGE, + "tunnel", + "--no-autoupdate", + "--url", + target_url, + ] + + logger.debug("Running: %s", " ".join(cmd)) + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + raise RuntimeError( + f"Failed to start tunnel container {tunnel_name}: {proc.stderr}" + ) + + container_id = proc.stdout.strip() + logger.debug("Tunnel container started: %s", container_id) + + # Wait for URL to appear in logs + # Exclude api.trycloudflare.com which is the Cloudflare API endpoint, + # not a tunnel URL. Real tunnel URLs have random subdomains (10+ chars). + url_pattern = re.compile(r"https://(?!api\.)[a-z0-9-]{10,}\.trycloudflare\.com") + start_time = __import__("time").time() + url: str | None = None + combined_logs = "" + + while __import__("time").time() - start_time < timeout: + stdout, stderr = _get_tunnel_logs(tunnel_name) + combined_logs = stdout + "\n" + stderr + + match = url_pattern.search(combined_logs) + if match: + url = match.group(0) + break + + # Check if container exited early + exit_code = _get_tunnel_exit_code(tunnel_name) + if exit_code is not None and exit_code != 0: + _cleanup_stale_tunnel(tunnel_name) + raise RuntimeError( + f"Tunnel container {tunnel_name} exited with code {exit_code}. " + f"Logs:\n{combined_logs[-3000:]}" + ) + + __import__("time").sleep(0.5) + + if not url: + stdout, stderr = _get_tunnel_logs(tunnel_name) + combined_logs = stdout + "\n" + stderr + exit_code = _get_tunnel_exit_code(tunnel_name) + + _cleanup_stale_tunnel(tunnel_name) + raise RuntimeError( + f"Tunnel {tunnel_name} did not produce a URL within {timeout}s. " + f"Exit code: {exit_code}. Logs:\n{combined_logs[-3000:]}" + ) + + # Wait a moment for Cloudflare DNS edge to propagate the new tunnel subdomain + __import__("time").sleep(2) + + logger.info( + "Tunnel %s started for %s → %s (%s)", + tunnel_name, + instance_name, + target_url, + url, + ) + return {"url": url, "container_name": tunnel_name} + + +def stop_tunnel(instance_name: str) -> None: + """Stop and remove the tunnel container for an instance.""" + tunnel_name = _tunnel_container_name(instance_name) + _cleanup_stale_tunnel(tunnel_name) + logger.debug("Stopped and removed tunnel container %s", tunnel_name) + + +def recreate_tunnel( + instance_name: str, container_port: int, target_url: str | None = None +) -> dict[str, str]: + """Recreate a tunnel for an instance. + + Args: + instance_name: The tool instance name. + container_port: The port the tool container listens on internally. + target_url: Optional explicit origin URL. If omitted, derives + http://{instance_name.lower()}:{container_port}. + """ + stop_tunnel(instance_name) + return start_tunnel(instance_name, container_port, target_url=target_url) + + +def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]: + """Check if a tunnel URL is healthy. + + Returns: + Dict with 'tunnel_status', 'status_code', 'healthy', 'error'. + """ + try: + result = subprocess.run( + [ + "curl", + "-s", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "--max-time", + str(timeout), + url, + ], + capture_output=True, + text=True, + timeout=timeout + 5, + ) + status_code = int(result.stdout.strip()) + + if 200 <= status_code < 400: + return { + "tunnel_status": "healthy", + "status_code": status_code, + "healthy": True, + "error": None, + } + if status_code in (502, 503, 504): + return { + "tunnel_status": "error_response", + "status_code": status_code, + "healthy": False, + "error": f"Application returned HTTP {status_code}", + } + return { + "tunnel_status": "error_response", + "status_code": status_code, + "healthy": False, + "error": f"HTTP {status_code}", + } + except subprocess.TimeoutExpired: + return { + "tunnel_status": "unreachable", + "status_code": None, + "healthy": False, + "error": "Tunnel request timed out", + } + except (ValueError, Exception) as exc: + error_str = str(exc).lower() + if any( + err in error_str + for err in [ + "connection refused", + "econnrefused", + "could not resolve", + "nodename", + ] + ): + return { + "tunnel_status": "unreachable", + "status_code": None, + "healthy": False, + "error": f"Tunnel unreachable: {exc}", + } + return { + "tunnel_status": "unreachable", + "status_code": None, + "healthy": False, + "error": str(exc), + } diff --git a/apps/api/src/services/tunnel.py b/apps/api/src/services/tunnel.py index 4c4d907..9853124 100644 --- a/apps/api/src/services/tunnel.py +++ b/apps/api/src/services/tunnel.py @@ -1,283 +1,8 @@ -"""Clean tunnel service using cloudflared containers on the backend network. +"""Tunnel service — re-exported from docker.tunnel for backward compatibility.""" -Design: -- Each tunnel runs as a Docker container on the same 'backend' network as the API. -- cloudflared connects to the tool container by its Docker Compose service name - (e.g. http://code-server-headquarter-34837cd3:8443). -- This avoids host port conflicts and DNS resolution issues. -""" - -import logging -import re -import subprocess -from typing import Any - -from src.services.docker import get_backend_network_name - -logger = logging.getLogger(__name__) - -TUNNEL_IMAGE = "cloudflare/cloudflared:latest" - - -def _tunnel_container_name(instance_name: str) -> str: - return f"tunnel-{instance_name.lower()}" - - -def _ensure_image() -> None: - """Pull cloudflared image if not already present.""" - result = subprocess.run( - ["docker", "images", "-q", TUNNEL_IMAGE], - capture_output=True, - text=True, - ) - if not result.stdout.strip(): - logger.info("Pulling %s ...", TUNNEL_IMAGE) - pull = subprocess.run( - ["docker", "pull", TUNNEL_IMAGE], - capture_output=True, - text=True, - ) - if pull.returncode != 0: - logger.warning("Failed to pull %s: %s", TUNNEL_IMAGE, pull.stderr) - - -def _cleanup_stale_tunnel(tunnel_name: str) -> None: - """Remove any existing tunnel container with this name.""" - subprocess.run( - ["docker", "stop", "-t", "3", tunnel_name], - capture_output=True, - text=True, - ) - subprocess.run( - ["docker", "rm", "-f", tunnel_name], - capture_output=True, - text=True, - ) - - -def _get_container_logs(tunnel_name: str) -> tuple[str, str]: - """Get stdout and stderr logs from a container.""" - result = subprocess.run( - ["docker", "logs", tunnel_name], - capture_output=True, - text=True, - ) - return result.stdout, result.stderr - - -def _get_container_exit_code(tunnel_name: str) -> int | None: - """Get exit code of a container if it has exited.""" - result = subprocess.run( - ["docker", "inspect", "-f", "{{.State.ExitCode}}", tunnel_name], - capture_output=True, - text=True, - ) - if result.returncode == 0: - try: - return int(result.stdout.strip()) - except ValueError: - pass - return None - - -def start_tunnel( - instance_name: str, - container_port: int, - timeout: int = 30, - target_url: str | None = None, -) -> dict[str, str]: - """Start a temporary Cloudflare tunnel for an instance. - - Args: - instance_name: The tool instance name (used for tunnel naming). - container_port: The port the tool container listens on internally. - timeout: Seconds to wait for the tunnel URL. - target_url: Optional explicit URL to proxy to. If omitted, derives - http://{instance_name.lower()}:{container_port}. - - Returns: - Dict with 'url' and 'container_name'. - """ - _ensure_image() - - tunnel_name = _tunnel_container_name(instance_name) - _cleanup_stale_tunnel(tunnel_name) - - # Target the tool container by name on the backend network - if target_url is None: - target_url = f"http://{instance_name.lower()}:{container_port}" - - cmd = [ - "docker", - "run", - "-d", - "--network", - get_backend_network_name(), - "--name", - tunnel_name, - TUNNEL_IMAGE, - "tunnel", - "--no-autoupdate", - "--url", - target_url, - ] - - logger.debug("Running: %s", " ".join(cmd)) - proc = subprocess.run(cmd, capture_output=True, text=True) - if proc.returncode != 0: - raise RuntimeError( - f"Failed to start tunnel container {tunnel_name}: {proc.stderr}" - ) - - container_id = proc.stdout.strip() - logger.debug("Tunnel container started: %s", container_id) - - # Wait for URL to appear in logs - # Exclude api.trycloudflare.com which is the Cloudflare API endpoint, - # not a tunnel URL. Real tunnel URLs have random subdomains (10+ chars). - url_pattern = re.compile(r"https://(?!api\.)[a-z0-9-]{10,}\.trycloudflare\.com") - start_time = __import__("time").time() - url: str | None = None - combined_logs = "" - - while __import__("time").time() - start_time < timeout: - stdout, stderr = _get_container_logs(tunnel_name) - combined_logs = stdout + "\n" + stderr - - match = url_pattern.search(combined_logs) - if match: - url = match.group(0) - break - - # Check if container exited early - exit_code = _get_container_exit_code(tunnel_name) - if exit_code is not None and exit_code != 0: - _cleanup_stale_tunnel(tunnel_name) - raise RuntimeError( - f"Tunnel container {tunnel_name} exited with code {exit_code}. " - f"Logs:\n{combined_logs[-3000:]}" - ) - - __import__("time").sleep(0.5) - - if not url: - stdout, stderr = _get_container_logs(tunnel_name) - combined_logs = stdout + "\n" + stderr - exit_code = _get_container_exit_code(tunnel_name) - - _cleanup_stale_tunnel(tunnel_name) - raise RuntimeError( - f"Tunnel {tunnel_name} did not produce a URL within {timeout}s. " - f"Exit code: {exit_code}. Logs:\n{combined_logs[-3000:]}" - ) - - # Wait a moment for Cloudflare DNS edge to propagate the new tunnel subdomain - __import__("time").sleep(2) - - logger.info( - "Tunnel %s started for %s → %s (%s)", - tunnel_name, - instance_name, - target_url, - url, - ) - return {"url": url, "container_name": tunnel_name} - - -def stop_tunnel(instance_name: str) -> None: - """Stop and remove the tunnel container for an instance.""" - tunnel_name = _tunnel_container_name(instance_name) - _cleanup_stale_tunnel(tunnel_name) - logger.debug("Stopped and removed tunnel container %s", tunnel_name) - - -def recreate_tunnel( - instance_name: str, container_port: int, target_url: str | None = None -) -> dict[str, str]: - """Recreate a tunnel for an instance. - - Args: - instance_name: The tool instance name. - container_port: The port the tool container listens on internally. - target_url: Optional explicit origin URL. If omitted, derives - http://{instance_name.lower()}:{container_port}. - """ - stop_tunnel(instance_name) - return start_tunnel(instance_name, container_port, target_url=target_url) - - -def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]: - """Check if a tunnel URL is healthy. - - Returns: - Dict with 'tunnel_status', 'status_code', 'healthy', 'error'. - """ - try: - result = subprocess.run( - [ - "curl", - "-s", - "-o", - "/dev/null", - "-w", - "%{http_code}", - "--max-time", - str(timeout), - url, - ], - capture_output=True, - text=True, - timeout=timeout + 5, - ) - status_code = int(result.stdout.strip()) - - if 200 <= status_code < 400: - return { - "tunnel_status": "healthy", - "status_code": status_code, - "healthy": True, - "error": None, - } - if status_code in (502, 503, 504): - return { - "tunnel_status": "error_response", - "status_code": status_code, - "healthy": False, - "error": f"Application returned HTTP {status_code}", - } - return { - "tunnel_status": "error_response", - "status_code": status_code, - "healthy": False, - "error": f"HTTP {status_code}", - } - except subprocess.TimeoutExpired: - return { - "tunnel_status": "unreachable", - "status_code": None, - "healthy": False, - "error": "Tunnel request timed out", - } - except (ValueError, Exception) as exc: - error_str = str(exc).lower() - if any( - err in error_str - for err in [ - "connection refused", - "econnrefused", - "could not resolve", - "nodename", - ] - ): - return { - "tunnel_status": "unreachable", - "status_code": None, - "healthy": False, - "error": f"Tunnel unreachable: {exc}", - } - return { - "tunnel_status": "unreachable", - "status_code": None, - "healthy": False, - "error": str(exc), - } +from src.services.docker.tunnel import ( # noqa: F401 + check_tunnel_health, + recreate_tunnel, + start_tunnel, + stop_tunnel, +) diff --git a/progress.md b/progress.md new file mode 100644 index 0000000..901f838 --- /dev/null +++ b/progress.md @@ -0,0 +1,30 @@ +# Progress + +## Status +In Progress — Backend structural refactoring + +## Tasks +- [x] Phase 0: Create submodule directory structure (__init__.py files) +- [x] Phase 1: Model subpackages (tool, config, user, project, system) +- [x] Phase 2: Docker service package split (compose, container, config_staging, tunnel) +- [ ] Phase 3: Service subpackages (instance, config, git, build, terminal, shared) +- [ ] Phase 4: Schema extraction (tool, config, user, project, system) +- [ ] Phase 5: API router subpackages (tool, config, workspace, user, project, system) +- [ ] Phase 6: Auth dependency refactor +- [ ] Phase 7: Frontend reorganization +- [ ] Phase 8: Integration and verification + +## Files Changed +- `apps/api/src/services/docker.py` → deleted (split into package) +- `apps/api/src/services/docker/__init__.py` — re-exports all public functions +- `apps/api/src/services/docker/compose.py` — compose generation & commands +- `apps/api/src/services/docker/container.py` — container runtime queries +- `apps/api/src/services/docker/config_staging.py` — file staging into instances +- `apps/api/src/services/docker/tunnel.py` — cloudflared tunnel lifecycle +- `apps/api/src/services/tunnel.py` — thin backward-compat re-export wrapper + +## Notes +- All backward-compatible imports preserved via __init__.py re-exports +- `from src.services.docker import X` continues to work for all previously exported symbols +- `from src.services.tunnel import X` continues to work via re-export wrapper +- No behavior changes, pure structural refactor