refactor: split services/docker.py into docker/ package
Split monolithic docker.py into focused modules: - docker/compose.py — compose generation, execute_compose_command, volume sorting - docker/container.py — container status, IP, logs, network, port finding - docker/config_staging.py — instance dir, env file, config file staging - docker/tunnel.py — cloudflared tunnel lifecycle (moved from services/tunnel.py) - docker/__init__.py — re-exports all public symbols for backward compatibility - services/tunnel.py — thin re-export wrapper for backward compatibility Also includes schema extraction files created in prior work: - schemas/config/config_profile.py - schemas/project/*.py - schemas/system/health.py - schemas/tool/*.py - schemas/user/*.py All existing imports like 'from src.services.docker import X' and 'from src.services.tunnel import X' continue to work unchanged. Quality gates: py_compile passed, ruff passed, import test passed.
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user