add7c1b500
- Standardize built-in tool users for shared writable profile mounts - Mount canonical non-Git profile sources across compatible instances - Report restart-required outcomes and guard active profile deletion - Surface restart feedback in config profile editing Quality gates: frontend build passed; backend py_compile and LSP passed. Skipped: backend pytest/Ruff unavailable; Docker/manual checks not approved.
288 lines
9.6 KiB
Python
288 lines
9.6 KiB
Python
"""Config profile request/response schemas."""
|
|
|
|
import uuid
|
|
|
|
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 as exc:
|
|
raise ValueError(f"Invalid UUID: {v}") from exc
|
|
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 as exc:
|
|
raise ValueError(f"Invalid UUID in includes: {item}") from exc
|
|
return v
|
|
|
|
|
|
class ConfigProfileRefreshOutcome(BaseModel):
|
|
instance_id: str
|
|
status: str
|
|
reason: str | None = None
|
|
|
|
|
|
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
|
|
refresh_outcomes: list[ConfigProfileRefreshOutcome] = Field(default_factory=list)
|
|
|
|
|
|
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
|