Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 597cfb9573 | |||
| 703cf1f88b | |||
| 5dc7d44111 | |||
| ee348643f8 | |||
| 05a598812b | |||
| 7515d9106f | |||
| 8c7affc933 | |||
| 2680a8c44a | |||
| 1021d61be3 | |||
| 7224afafd1 | |||
| 020f832eed | |||
| 7fe2790199 | |||
| 38c51ed95e | |||
| 37ccaa4fdc | |||
| 8816ee02ce | |||
| 6104f592eb |
@@ -1,6 +1 @@
|
|||||||
from src.api.auth import router as auth_router
|
"""API routers package."""
|
||||||
from src.api.events import router as events_router
|
|
||||||
from src.api.notifications import router as notifications_router
|
|
||||||
from src.api.users import router as users_router
|
|
||||||
|
|
||||||
__all__ = ["auth_router", "events_router", "notifications_router", "users_router"]
|
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
"""Config module."""
|
"""Config API routers module."""
|
||||||
|
|
||||||
|
from src.api.config.config_profiles import router as config_profiles_router
|
||||||
|
from src.api.config.user_config import router as user_config_router
|
||||||
|
|
||||||
|
__all__ = ["config_profiles_router", "user_config_router"]
|
||||||
|
|||||||
@@ -7,18 +7,25 @@ import uuid
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from src.api.shared_validators import validate_env_vars as _validate_env_vars
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models import ConfigProfile, ConfigProfileInclude
|
from src.models import ConfigProfile, ConfigProfileInclude
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models import ToolType
|
from src.models import ToolType
|
||||||
from src.models import UserConfig
|
from src.models import UserConfig
|
||||||
from src.services.config_profile_resolver import (
|
from src.schemas.config import (
|
||||||
|
ConfigProfileCreate,
|
||||||
|
ConfigProfileIncludeUpdate,
|
||||||
|
ConfigProfileResponse,
|
||||||
|
ConfigProfileUpdate,
|
||||||
|
DefaultProfilesUpdate,
|
||||||
|
ValidateGitUrlRequest,
|
||||||
|
ValidateGitUrlResponse,
|
||||||
|
)
|
||||||
|
from src.services.config.config_profile_resolver import (
|
||||||
ConfigProfileCycleError,
|
ConfigProfileCycleError,
|
||||||
check_include_cycle,
|
check_include_cycle,
|
||||||
resolve_profile,
|
resolve_profile,
|
||||||
@@ -34,16 +41,6 @@ MAX_PROFILE_SIZE_MB = 10
|
|||||||
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
|
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_profile_size(data: dict) -> int:
|
def _calculate_profile_size(data: dict) -> int:
|
||||||
"""Calculate approximate serialized size of profile data."""
|
"""Calculate approximate serialized size of profile data."""
|
||||||
total = 0
|
total = 0
|
||||||
@@ -61,247 +58,6 @@ def _calculate_profile_size(data: dict) -> int:
|
|||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
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.keys():
|
|
||||||
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.keys():
|
|
||||||
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.keys():
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_profile_with_includes(
|
async def _get_profile_with_includes(
|
||||||
session: AsyncSession, profile_id: uuid.UUID
|
session: AsyncSession, profile_id: uuid.UUID
|
||||||
) -> ConfigProfile | None:
|
) -> ConfigProfile | None:
|
||||||
@@ -846,12 +602,6 @@ async def resolve_default_profile(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class DefaultProfilesUpdate(BaseModel):
|
|
||||||
default_profiles: dict[str, str] = Field(
|
|
||||||
description="Mapping of tool_type_id -> profile_id for default profiles"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_or_create_user_config(
|
async def _get_or_create_user_config(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
@@ -940,22 +690,6 @@ async def get_default_profile_for_tool_type_endpoint(
|
|||||||
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
|
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
|
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
|
||||||
async def validate_git_url(
|
async def validate_git_url(
|
||||||
data: ValidateGitUrlRequest,
|
data: ValidateGitUrlRequest,
|
||||||
@@ -992,7 +726,7 @@ async def validate_git_url(
|
|||||||
key_path = None
|
key_path = None
|
||||||
if data.ssh_key_id:
|
if data.ssh_key_id:
|
||||||
from src.models import SSHKey
|
from src.models import SSHKey
|
||||||
from src.services.ssh_keys import _get_fernet
|
from src.services.shared.ssh_keys import _get_fernet
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ssh_key_uuid = uuid.UUID(data.ssh_key_id)
|
ssh_key_uuid = uuid.UUID(data.ssh_key_id)
|
||||||
@@ -2,12 +2,12 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.models import UserConfig
|
from src.models import UserConfig
|
||||||
|
from src.schemas.user import UserConfigResponse, UserConfigUpdate
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -38,28 +38,6 @@ async def _get_or_create_config(
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/config",
|
"/config",
|
||||||
response_model=UserConfigResponse,
|
response_model=UserConfigResponse,
|
||||||
@@ -1 +1,6 @@
|
|||||||
"""Project module."""
|
"""Project API routers module."""
|
||||||
|
|
||||||
|
from src.api.project.git_repositories import router as git_repositories_router
|
||||||
|
from src.api.project.projects import router as projects_router
|
||||||
|
|
||||||
|
__all__ = ["git_repositories_router", "projects_router"]
|
||||||
|
|||||||
+9
-44
@@ -3,10 +3,9 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -19,6 +18,13 @@ from src.auth.dependencies import (
|
|||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models import GitRepository
|
from src.models import GitRepository
|
||||||
from src.models import SSHKey
|
from src.models import SSHKey
|
||||||
|
from src.schemas.project import (
|
||||||
|
GitRepositoryCreate,
|
||||||
|
GitRepositoryResponse,
|
||||||
|
URLParseRequest,
|
||||||
|
URLParseResponse,
|
||||||
|
UpdateSSHKeyRequest,
|
||||||
|
)
|
||||||
from src.utils.git_files import (
|
from src.utils.git_files import (
|
||||||
commit_file,
|
commit_file,
|
||||||
get_file_content,
|
get_file_content,
|
||||||
@@ -38,7 +44,7 @@ from src.utils.git_control import (
|
|||||||
)
|
)
|
||||||
from src.utils.git_history import get_commit_detail, get_commit_history
|
from src.utils.git_history import get_commit_detail, get_commit_history
|
||||||
from src.utils.git_url_parser import parse_git_url
|
from src.utils.git_url_parser import parse_git_url
|
||||||
from src.services.ssh_keys import _get_fernet
|
from src.services.shared.ssh_keys import _get_fernet
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
||||||
|
|
||||||
@@ -218,43 +224,6 @@ def _init_working_repository(repo_path: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/repositories",
|
"/repositories",
|
||||||
response_model=list[GitRepositoryResponse],
|
response_model=list[GitRepositoryResponse],
|
||||||
@@ -599,10 +568,6 @@ async def create_repository(
|
|||||||
return repo
|
return repo
|
||||||
|
|
||||||
|
|
||||||
class UpdateSSHKeyRequest(BaseModel):
|
|
||||||
ssh_key_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/{project_id}/repositories/{repo_id}/ssh-key",
|
"/{project_id}/repositories/{repo_id}/ssh-key",
|
||||||
response_model=GitRepositoryResponse,
|
response_model=GitRepositoryResponse,
|
||||||
@@ -3,7 +3,6 @@ import shutil
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -17,34 +16,16 @@ from src.models import GitRepository
|
|||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models import SSHKey
|
from src.models import SSHKey
|
||||||
from src.models import ToolInstance
|
from src.models import ToolInstance
|
||||||
|
from src.schemas.project import (
|
||||||
|
ProjectCreate,
|
||||||
|
ProjectResponse,
|
||||||
|
ProjectUpdate,
|
||||||
|
SetDefaultSSHKeyRequest,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=ProjectResponse,
|
response_model=ProjectResponse,
|
||||||
@@ -1 +1,17 @@
|
|||||||
"""System module."""
|
"""System API routers module."""
|
||||||
|
|
||||||
|
from src.api.system.dashboard import router as dashboard_router
|
||||||
|
from src.api.system.events import router as events_router
|
||||||
|
from src.api.system.health import router as health_router
|
||||||
|
from src.api.system.instance_proxy import router as instance_proxy_router
|
||||||
|
from src.api.system.notifications import router as notifications_router
|
||||||
|
from src.api.system.terminal import router as terminal_router
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"dashboard_router",
|
||||||
|
"events_router",
|
||||||
|
"health_router",
|
||||||
|
"instance_proxy_router",
|
||||||
|
"notifications_router",
|
||||||
|
"terminal_router",
|
||||||
|
]
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id
|
from src.auth.dependencies import get_current_user_id
|
||||||
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload
|
||||||
|
|
||||||
router = APIRouter(prefix="/events", tags=["events"])
|
router = APIRouter(prefix="/events", tags=["events"])
|
||||||
|
|
||||||
@@ -5,10 +5,16 @@ from datetime import datetime, timezone
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
|
from src.schemas.system import (
|
||||||
|
DatabaseHealth,
|
||||||
|
DatabaseHealthResponse,
|
||||||
|
DiskHealth,
|
||||||
|
HealthChecks,
|
||||||
|
HealthResponse,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -16,45 +22,6 @@ router = APIRouter()
|
|||||||
_start_time = time.time()
|
_start_time = time.time()
|
||||||
|
|
||||||
|
|
||||||
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])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/health",
|
"/health",
|
||||||
response_model=HealthResponse,
|
response_model=HealthResponse,
|
||||||
@@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.auth.dependencies import get_current_user, get_db_session
|
from src.auth.dependencies import get_current_user, get_db_session
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.models import UserConfig
|
from src.models import UserConfig
|
||||||
from src.services.notification_service import notification_service
|
from src.services.shared.notification_service import notification_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ from src.auth.dependencies import get_current_user_id, get_db_session
|
|||||||
from src.models import TerminalSessionModel
|
from src.models import TerminalSessionModel
|
||||||
from src.models import ToolInstance
|
from src.models import ToolInstance
|
||||||
from src.models import ToolType
|
from src.models import ToolType
|
||||||
from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager
|
from src.services.terminal.terminal_manager import MaxSessionsExceededError, terminal_manager
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -1 +1,13 @@
|
|||||||
"""Tool module."""
|
"""Tool API routers module."""
|
||||||
|
|
||||||
|
from src.api.tool.sessions import sessions_router
|
||||||
|
from src.api.tool.tool_definitions import router as tool_definitions_router
|
||||||
|
from src.api.tool.tool_instances import router as tool_instances_router
|
||||||
|
from src.api.tool.tool_types import router as tool_types_router
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"sessions_router",
|
||||||
|
"tool_definitions_router",
|
||||||
|
"tool_instances_router",
|
||||||
|
"tool_types_router",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Sessions API endpoints (running instances for current user)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
|
from src.models import GitRepository
|
||||||
|
from src.models import Project
|
||||||
|
from src.models import ToolInstance
|
||||||
|
from src.models import ToolType
|
||||||
|
|
||||||
|
sessions_router = APIRouter(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_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
|
||||||
|
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.interface_type] 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,
|
||||||
|
"clone_mode": instance.clone_mode,
|
||||||
|
"branch": instance.branch,
|
||||||
|
"selected_config_profile_id": str(instance.selected_config_profile_id)
|
||||||
|
if instance.selected_config_profile_id
|
||||||
|
else None,
|
||||||
|
"created_at": instance.created_at.isoformat()
|
||||||
|
if instance.created_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"sessions": sessions}
|
||||||
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models import ToolDefinitionManifest
|
from src.models import ToolDefinitionManifest
|
||||||
from src.models import ToolType
|
from src.models import ToolType
|
||||||
from src.services.manifest_compiler import (
|
from src.services.build.manifest_compiler import (
|
||||||
compile_compose,
|
compile_compose,
|
||||||
compile_dockerfile,
|
compile_dockerfile,
|
||||||
compile_entrypoint,
|
compile_entrypoint,
|
||||||
@@ -173,7 +173,7 @@ async def list_tool_definitions(
|
|||||||
"""
|
"""
|
||||||
query = select(ToolDefinitionManifest)
|
query = select(ToolDefinitionManifest)
|
||||||
if not include_bases:
|
if not include_bases:
|
||||||
query = query.where(ToolDefinitionManifest.is_base == False)
|
query = query.where(ToolDefinitionManifest.is_base.is_(False))
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
query.order_by(ToolDefinitionManifest.created_at.desc())
|
query.order_by(ToolDefinitionManifest.created_at.desc())
|
||||||
@@ -11,14 +11,12 @@ from datetime import datetime
|
|||||||
import httpx
|
import httpx
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
APIRouter as FastAPIRouter,
|
|
||||||
Depends,
|
Depends,
|
||||||
HTTPException,
|
HTTPException,
|
||||||
Request,
|
Request,
|
||||||
Response,
|
Response,
|
||||||
status,
|
status,
|
||||||
)
|
)
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -28,16 +26,15 @@ from src.auth.dependencies import (
|
|||||||
get_current_user_id,
|
get_current_user_id,
|
||||||
get_db_session,
|
get_db_session,
|
||||||
)
|
)
|
||||||
from src.services.event_bus import InstanceEventBus
|
from src.services.instance.event_bus import InstanceEventBus
|
||||||
from src.services.lifecycle_hooks import publish_lifecycle_event
|
from src.services.instance.lifecycle_hooks import publish_lifecycle_event
|
||||||
from src.models import ConfigProfile
|
from src.models import ConfigProfile
|
||||||
from src.models import GitRepository
|
from src.models import GitRepository
|
||||||
from src.models.project import Project
|
|
||||||
from src.models import SSHKey
|
from src.models import SSHKey
|
||||||
from src.models import ToolInstance
|
from src.models import ToolInstance
|
||||||
from src.models import ToolType
|
from src.models import ToolType
|
||||||
from src.services.clone import check_dirty_state, clone_repository
|
from src.services.git.clone import check_dirty_state, clone_repository
|
||||||
from src.services.config_profile_resolver import (
|
from src.services.config.config_profile_resolver import (
|
||||||
ConfigProfileCycleError,
|
ConfigProfileCycleError,
|
||||||
ResolvedProfile,
|
ResolvedProfile,
|
||||||
apply_resolved_profile,
|
apply_resolved_profile,
|
||||||
@@ -62,14 +59,14 @@ from src.services.docker import (
|
|||||||
write_config_files,
|
write_config_files,
|
||||||
write_env_file,
|
write_env_file,
|
||||||
)
|
)
|
||||||
from src.services.tunnel import (
|
from src.services.shared.tunnel import (
|
||||||
check_tunnel_health,
|
check_tunnel_health,
|
||||||
recreate_tunnel,
|
recreate_tunnel,
|
||||||
start_tunnel,
|
start_tunnel,
|
||||||
stop_tunnel,
|
stop_tunnel,
|
||||||
)
|
)
|
||||||
from src.services.docker_build import build_image
|
from src.services.build.docker_build import build_image
|
||||||
from src.services.manifest_compiler import (
|
from src.services.build.manifest_compiler import (
|
||||||
compile_compose,
|
compile_compose,
|
||||||
compile_dockerfile,
|
compile_dockerfile,
|
||||||
compile_entrypoint,
|
compile_entrypoint,
|
||||||
@@ -79,9 +76,13 @@ from src.services.manifest_compiler import (
|
|||||||
merge_with_config,
|
merge_with_config,
|
||||||
resolve_base,
|
resolve_base,
|
||||||
)
|
)
|
||||||
from src.services.permission_fixer import apply_mount_permissions, apply_ssh_permissions
|
from src.services.shared.permission_fixer import (
|
||||||
from src.services.readiness_probe import execute_probe
|
apply_mount_permissions,
|
||||||
from src.services.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files
|
apply_ssh_permissions,
|
||||||
|
)
|
||||||
|
from src.services.shared.readiness_probe import execute_probe
|
||||||
|
from src.services.shared.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files
|
||||||
|
from src.schemas.tool import CreateInstanceRequest, StartInstanceRequest
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
_event_bus = InstanceEventBus()
|
_event_bus = InstanceEventBus()
|
||||||
@@ -418,48 +419,6 @@ def _expand_glob_source(source_path: str, repo_path: str) -> list[str]:
|
|||||||
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
||||||
|
|
||||||
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_config_profile(
|
async def _validate_config_profile(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
profile_id: str | None,
|
profile_id: str | None,
|
||||||
@@ -894,19 +853,31 @@ async def create_instance(
|
|||||||
# Generate unique name
|
# Generate unique name
|
||||||
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
|
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
# Auto-generate display name with numbering when duplicates exist
|
# Auto-generate display name with scoped numbering.
|
||||||
|
# When a workspace is provided, use workspace name + tool type.
|
||||||
|
# Otherwise fall back to repo name + tool type.
|
||||||
if data.display_name:
|
if data.display_name:
|
||||||
instance_display = data.display_name
|
instance_display = data.display_name
|
||||||
else:
|
else:
|
||||||
auto_name = f"{_project.name} / {repo.name} / {tool_type.display_name}"
|
scope_name = workspace.name if workspace else repo.name
|
||||||
result = await session.execute(
|
auto_name = f"{scope_name} / {tool_type.display_name}"
|
||||||
select(ToolInstance).where(
|
|
||||||
ToolInstance.project_id == project_id,
|
if workspace:
|
||||||
ToolInstance.repository_id == repo_id,
|
count_query = (
|
||||||
ToolInstance.tool_type_id == tool_type_id,
|
select(ToolInstance)
|
||||||
ToolInstance.owner_id == user_id,
|
.where(ToolInstance.workspace_id == workspace_id)
|
||||||
|
.where(ToolInstance.tool_type_id == tool_type_id)
|
||||||
|
.where(ToolInstance.owner_id == user_id)
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
count_query = (
|
||||||
|
select(ToolInstance)
|
||||||
|
.where(ToolInstance.repository_id == repo_id)
|
||||||
|
.where(ToolInstance.tool_type_id == tool_type_id)
|
||||||
|
.where(ToolInstance.owner_id == user_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result = await session.execute(count_query)
|
||||||
existing_count = len(result.scalars().all())
|
existing_count = len(result.scalars().all())
|
||||||
if existing_count > 0:
|
if existing_count > 0:
|
||||||
instance_display = f"{auto_name} #{existing_count + 1}"
|
instance_display = f"{auto_name} #{existing_count + 1}"
|
||||||
@@ -1627,7 +1598,7 @@ async def start_instance(
|
|||||||
|
|
||||||
# Mount selected SSH keys into container home dir
|
# Mount selected SSH keys into container home dir
|
||||||
if instance.ssh_key_ids:
|
if instance.ssh_key_ids:
|
||||||
from src.services.ssh_keys import write_ssh_config, _sanitize_filename
|
from src.services.shared.ssh_keys import write_ssh_config, _sanitize_filename
|
||||||
|
|
||||||
# Collect all valid keys first
|
# Collect all valid keys first
|
||||||
ssh_keys_to_mount = []
|
ssh_keys_to_mount = []
|
||||||
@@ -2927,71 +2898,3 @@ async def proxy_to_instance(
|
|||||||
status_code=response.status_code,
|
status_code=response.status_code,
|
||||||
headers=response_headers,
|
headers=response_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
_user = await _get_user(session, user_id)
|
|
||||||
|
|
||||||
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.interface_type] 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,
|
|
||||||
"clone_mode": instance.clone_mode,
|
|
||||||
"branch": instance.branch,
|
|
||||||
"selected_config_profile_id": str(instance.selected_config_profile_id)
|
|
||||||
if instance.selected_config_profile_id
|
|
||||||
else None,
|
|
||||||
"created_at": instance.created_at.isoformat()
|
|
||||||
if instance.created_at
|
|
||||||
else None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"sessions": sessions}
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.api.tool_types_validation import (
|
from src.api.tool.tool_types_validation import (
|
||||||
check_port_exposed,
|
check_port_exposed,
|
||||||
validate_compose_yaml,
|
validate_compose_yaml,
|
||||||
validate_required_variables,
|
validate_required_variables,
|
||||||
@@ -14,6 +12,12 @@ from src.api.tool_types_validation import (
|
|||||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.models import ToolType
|
from src.models import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
from src.schemas.tool import (
|
||||||
|
ToolTypeCreate,
|
||||||
|
ToolTypeResponse,
|
||||||
|
ToolTypeUpdate,
|
||||||
|
ToolTypeValidateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
||||||
|
|
||||||
@@ -29,237 +33,6 @@ async def _require_admin(user: User) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
for var in v:
|
|
||||||
placeholder = f"{{{{{var}}}}}"
|
|
||||||
if placeholder not in template:
|
|
||||||
raise ValueError(
|
|
||||||
f"Required variable '{var}' not found in compose template"
|
|
||||||
)
|
|
||||||
|
|
||||||
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'"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate that default_port is exposed in compose template (only if requires_port)
|
|
||||||
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
|
|
||||||
|
|
||||||
data = info.data
|
|
||||||
definition_type = data.get("definition_type")
|
|
||||||
if definition_type and definition_type != "compose":
|
|
||||||
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
|
|
||||||
|
|
||||||
data = info.data
|
|
||||||
definition_type = data.get("definition_type")
|
|
||||||
if definition_type and definition_type != "dockerfile":
|
|
||||||
return v
|
|
||||||
|
|
||||||
if not v.strip().startswith("FROM"):
|
|
||||||
raise ValueError("Dockerfile must start with a FROM instruction")
|
|
||||||
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeResponse(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: uuid.UUID
|
|
||||||
name: str
|
|
||||||
display_name: str
|
|
||||||
description: str | None
|
|
||||||
category: str
|
|
||||||
interface_type: str
|
|
||||||
requires_port: bool
|
|
||||||
default_port: int
|
|
||||||
definition_type: str
|
|
||||||
manifest_id: uuid.UUID | None
|
|
||||||
compose_template: str | None
|
|
||||||
dockerfile_template: str | None
|
|
||||||
build_context: dict | None
|
|
||||||
readiness_probe: dict | None
|
|
||||||
startup_command: str | None
|
|
||||||
required_variables: list[str]
|
|
||||||
created_by_id: uuid.UUID | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=ToolTypeResponse,
|
response_model=ToolTypeResponse,
|
||||||
@@ -461,12 +234,6 @@ async def update_tool_type(
|
|||||||
return tool_type
|
return tool_type
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeValidateRequest(BaseModel):
|
|
||||||
definition_type: str
|
|
||||||
compose_template: str | None = None
|
|
||||||
dockerfile_template: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/validate",
|
"/validate",
|
||||||
summary="Validate tool type template",
|
summary="Validate tool type template",
|
||||||
@@ -1 +1,7 @@
|
|||||||
"""User module."""
|
"""User API routers module."""
|
||||||
|
|
||||||
|
from src.api.user.auth import router as auth_router
|
||||||
|
from src.api.user.ssh_keys import router as ssh_keys_router
|
||||||
|
from src.api.user.users import router as users_router
|
||||||
|
|
||||||
|
__all__ = ["auth_router", "ssh_keys_router", "users_router"]
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import base64
|
import base64
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models import SSHKey
|
from src.models import SSHKey
|
||||||
|
from src.schemas.project import (
|
||||||
|
SSHKeyCreate,
|
||||||
|
SSHKeyResponse,
|
||||||
|
SignPayloadRequest,
|
||||||
|
SignatureResponse,
|
||||||
|
VerifySignatureRequest,
|
||||||
|
VerifySignatureResponse,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
||||||
|
|
||||||
@@ -53,36 +59,6 @@ def generate_ssh_key_pair() -> tuple[str, str]:
|
|||||||
return private_bytes.decode("utf-8"), public_bytes.decode("utf-8")
|
return private_bytes.decode("utf-8"), public_bytes.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=SSHKeyResponse,
|
response_model=SSHKeyResponse,
|
||||||
@@ -171,7 +147,9 @@ async def delete_ssh_key(
|
|||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
ssh_key = await session.get(SSHKey, key_id)
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
if ssh_key is None or ssh_key.user_id != user.id:
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
|
||||||
|
)
|
||||||
|
|
||||||
await session.delete(ssh_key)
|
await session.delete(ssh_key)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -203,7 +181,9 @@ async def sign_payload(
|
|||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
ssh_key = await session.get(SSHKey, key_id)
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
if ssh_key is None or ssh_key.user_id != user.id:
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
|
||||||
|
)
|
||||||
|
|
||||||
fernet = _get_fernet()
|
fernet = _get_fernet()
|
||||||
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
@@ -242,7 +222,9 @@ async def verify_signature(
|
|||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
ssh_key = await session.get(SSHKey, key_id)
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
if ssh_key is None or ssh_key.user_id != user.id:
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
|
||||||
|
)
|
||||||
|
|
||||||
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
|
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
|
||||||
|
|
||||||
@@ -2,11 +2,11 @@ import uuid
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
from src.schemas.user import UserProfileResponse, UserProfileUpdate
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
@@ -16,20 +16,6 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
|
|||||||
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/me",
|
"/me",
|
||||||
response_model=UserProfileResponse,
|
response_model=UserProfileResponse,
|
||||||
@@ -77,12 +63,16 @@ async def update_profile(
|
|||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
if len(data.name.strip()) == 0:
|
if len(data.name.strip()) == 0:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty"
|
||||||
|
)
|
||||||
user.name = data.name.strip()
|
user.name = data.name.strip()
|
||||||
|
|
||||||
if data.email is not None:
|
if data.email is not None:
|
||||||
if "@" not in data.email:
|
if "@" not in data.email:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email"
|
||||||
|
)
|
||||||
user.email = data.email.strip()
|
user.email = data.email.strip()
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -1 +1,17 @@
|
|||||||
"""Workspace module."""
|
"""Workspace API routers module."""
|
||||||
|
|
||||||
|
from src.api.workspace.workspace_files import router as workspace_files_router
|
||||||
|
from src.api.workspace.workspace_git import router as workspace_git_router
|
||||||
|
from src.api.workspace.workspace_instances import router as workspace_instances_router
|
||||||
|
from src.api.workspace.workspaces import (
|
||||||
|
all_workspaces_router,
|
||||||
|
router as workspaces_router,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"all_workspaces_router",
|
||||||
|
"workspace_files_router",
|
||||||
|
"workspace_git_router",
|
||||||
|
"workspace_instances_router",
|
||||||
|
"workspaces_router",
|
||||||
|
]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models import Workspace
|
from src.models import Workspace
|
||||||
from src.services.file_service import FileService
|
from src.services.shared.file_service import FileService
|
||||||
|
|
||||||
router = APIRouter(prefix="/workspaces/{workspace_id}/files")
|
router = APIRouter(prefix="/workspaces/{workspace_id}/files")
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ async def write_file(
|
|||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
if commit_message:
|
if commit_message:
|
||||||
from src.services.git_operations import GitOperations
|
from src.services.git.git_operations import GitOperations
|
||||||
|
|
||||||
git = GitOperations(workspace)
|
git = GitOperations(workspace)
|
||||||
try:
|
try:
|
||||||
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models import Workspace
|
from src.models import Workspace
|
||||||
from src.services.git_operations import GitOperations
|
from src.services.git.git_operations import GitOperations
|
||||||
|
|
||||||
router = APIRouter(prefix="/workspaces/{workspace_id}/git")
|
router = APIRouter(prefix="/workspaces/{workspace_id}/git")
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ from src.auth.dependencies import get_current_user_id, get_db_session
|
|||||||
from src.models import GitRepository
|
from src.models import GitRepository
|
||||||
from src.models import ToolInstance
|
from src.models import ToolInstance
|
||||||
from src.models import Workspace
|
from src.models import Workspace
|
||||||
from src.services.workspace_manager import WorkspaceHasInstancesError, WorkspaceManager
|
from src.services.shared.workspace_manager import WorkspaceHasInstancesError, WorkspaceManager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ from collections.abc import Callable
|
|||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from src.services.correlation import get_correlation_id
|
from src.services.shared.correlation import get_correlation_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
+26
-24
@@ -7,27 +7,30 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from src.api.auth import router as auth_router
|
from src.api.config import config_profiles_router, user_config_router
|
||||||
from src.api.dashboard import router as dashboard_router
|
from src.api.project import git_repositories_router, projects_router
|
||||||
from src.api.events import router as events_router
|
from src.api.system import (
|
||||||
from src.api.git_repositories import router as git_repositories_router
|
dashboard_router,
|
||||||
from src.api.health import router as health_router
|
events_router,
|
||||||
from src.api.projects import router as projects_router
|
health_router,
|
||||||
from src.api.ssh_keys import router as ssh_keys_router
|
instance_proxy_router,
|
||||||
from src.api.terminal import router as terminal_router
|
notifications_router,
|
||||||
from src.api.instance_proxy import router as instance_proxy_router
|
terminal_router,
|
||||||
from src.api.config_profiles import router as config_profiles_router
|
)
|
||||||
from src.api.tool_definitions import router as tool_definitions_router
|
from src.api.tool import (
|
||||||
from src.api.tool_instances import router as tool_instances_router
|
sessions_router,
|
||||||
from src.api.tool_instances import sessions_router
|
tool_definitions_router,
|
||||||
from src.api.tool_types import router as tool_types_router
|
tool_instances_router,
|
||||||
from src.api.notifications import router as notifications_router
|
tool_types_router,
|
||||||
from src.api.user_config import router as user_config_router
|
)
|
||||||
from src.api.users import router as users_router
|
from src.api.user import auth_router, ssh_keys_router, users_router
|
||||||
from src.api.workspace_files import router as workspace_files_router
|
from src.api.workspace import (
|
||||||
from src.api.workspace_git import router as workspace_git_router
|
all_workspaces_router,
|
||||||
from src.api.workspace_instances import router as workspace_instances_router
|
workspace_files_router,
|
||||||
from src.api.workspaces import all_workspaces_router, router as workspaces_router
|
workspace_git_router,
|
||||||
|
workspace_instances_router,
|
||||||
|
workspaces_router,
|
||||||
|
)
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models import Notification # noqa: F401 – Alembic model discovery
|
from src.models import Notification # noqa: F401 – Alembic model discovery
|
||||||
from src.models import TerminalSessionModel # noqa: F401 – Alembic model discovery
|
from src.models import TerminalSessionModel # noqa: F401 – Alembic model discovery
|
||||||
@@ -38,9 +41,8 @@ from src.logging_config import (
|
|||||||
configure_logging,
|
configure_logging,
|
||||||
)
|
)
|
||||||
from src.seeds.builtin_tool_types import seed_builtin_tool_types
|
from src.seeds.builtin_tool_types import seed_builtin_tool_types
|
||||||
from src.services.correlation import CorrelationIdMiddleware
|
from src.services.instance import InstanceEventBus, HealthMonitor
|
||||||
from src.services.event_bus import InstanceEventBus
|
from src.services.shared import CorrelationIdMiddleware
|
||||||
from src.services.health_monitor import HealthMonitor
|
|
||||||
|
|
||||||
# Configure logging early
|
# Configure logging early
|
||||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
|
|||||||
@@ -1 +1,27 @@
|
|||||||
"""Config module."""
|
"""Config schemas module."""
|
||||||
|
|
||||||
|
from src.schemas.config.config_profile import (
|
||||||
|
ConfigProfileCreate,
|
||||||
|
ConfigProfileIncludeUpdate,
|
||||||
|
ConfigProfileResponse,
|
||||||
|
ConfigProfileUpdate,
|
||||||
|
DefaultProfilesUpdate,
|
||||||
|
GitMountItem,
|
||||||
|
GitMountMapping,
|
||||||
|
MountItem,
|
||||||
|
ValidateGitUrlRequest,
|
||||||
|
ValidateGitUrlResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ConfigProfileCreate",
|
||||||
|
"ConfigProfileIncludeUpdate",
|
||||||
|
"ConfigProfileResponse",
|
||||||
|
"ConfigProfileUpdate",
|
||||||
|
"DefaultProfilesUpdate",
|
||||||
|
"GitMountItem",
|
||||||
|
"GitMountMapping",
|
||||||
|
"MountItem",
|
||||||
|
"ValidateGitUrlRequest",
|
||||||
|
"ValidateGitUrlResponse",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""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 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
|
||||||
@@ -1 +1,41 @@
|
|||||||
"""Project module."""
|
"""Project schemas module."""
|
||||||
|
|
||||||
|
from src.schemas.project.git_repository import (
|
||||||
|
GitRepositoryCreate,
|
||||||
|
GitRepositoryResponse,
|
||||||
|
UpdateSSHKeyRequest,
|
||||||
|
URLParseRequest,
|
||||||
|
URLParseResponse,
|
||||||
|
)
|
||||||
|
from src.schemas.project.project import (
|
||||||
|
ProjectCreate,
|
||||||
|
ProjectResponse,
|
||||||
|
ProjectUpdate,
|
||||||
|
SetDefaultSSHKeyRequest,
|
||||||
|
)
|
||||||
|
from src.schemas.project.ssh_key import (
|
||||||
|
SSHKeyCreate,
|
||||||
|
SSHKeyResponse,
|
||||||
|
SignPayloadRequest,
|
||||||
|
SignatureResponse,
|
||||||
|
VerifySignatureRequest,
|
||||||
|
VerifySignatureResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GitRepositoryCreate",
|
||||||
|
"GitRepositoryResponse",
|
||||||
|
"ProjectCreate",
|
||||||
|
"ProjectResponse",
|
||||||
|
"ProjectUpdate",
|
||||||
|
"SSHKeyCreate",
|
||||||
|
"SSHKeyResponse",
|
||||||
|
"SetDefaultSSHKeyRequest",
|
||||||
|
"SignPayloadRequest",
|
||||||
|
"SignatureResponse",
|
||||||
|
"URLParseRequest",
|
||||||
|
"URLParseResponse",
|
||||||
|
"UpdateSSHKeyRequest",
|
||||||
|
"VerifySignatureRequest",
|
||||||
|
"VerifySignatureResponse",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1 +1,17 @@
|
|||||||
"""System module."""
|
"""System schemas module."""
|
||||||
|
|
||||||
|
from src.schemas.system.health import (
|
||||||
|
DatabaseHealth,
|
||||||
|
DatabaseHealthResponse,
|
||||||
|
DiskHealth,
|
||||||
|
HealthChecks,
|
||||||
|
HealthResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DatabaseHealth",
|
||||||
|
"DatabaseHealthResponse",
|
||||||
|
"DiskHealth",
|
||||||
|
"HealthChecks",
|
||||||
|
"HealthResponse",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
)
|
||||||
@@ -1 +1,18 @@
|
|||||||
"""Tool module."""
|
"""Tool schemas module."""
|
||||||
|
|
||||||
|
from src.schemas.tool.tool_instance import CreateInstanceRequest, StartInstanceRequest
|
||||||
|
from src.schemas.tool.tool_type import (
|
||||||
|
ToolTypeCreate,
|
||||||
|
ToolTypeResponse,
|
||||||
|
ToolTypeUpdate,
|
||||||
|
ToolTypeValidateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CreateInstanceRequest",
|
||||||
|
"StartInstanceRequest",
|
||||||
|
"ToolTypeCreate",
|
||||||
|
"ToolTypeResponse",
|
||||||
|
"ToolTypeUpdate",
|
||||||
|
"ToolTypeValidateRequest",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""Tool type request/response schemas."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
|
|
||||||
|
from src.api.tool.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
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTypeResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
description: str | None
|
||||||
|
category: str
|
||||||
|
interface_type: str
|
||||||
|
requires_port: bool
|
||||||
|
default_port: int
|
||||||
|
definition_type: str
|
||||||
|
manifest_id: uuid.UUID | None
|
||||||
|
compose_template: str | None
|
||||||
|
dockerfile_template: str | None
|
||||||
|
build_context: dict | None
|
||||||
|
readiness_probe: dict | None
|
||||||
|
startup_command: str | None
|
||||||
|
required_variables: list[str]
|
||||||
|
created_by_id: uuid.UUID | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTypeValidateRequest(BaseModel):
|
||||||
|
definition_type: str
|
||||||
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
@@ -1 +1,11 @@
|
|||||||
"""User module."""
|
"""User schemas module."""
|
||||||
|
|
||||||
|
from src.schemas.user.user import UserProfileResponse, UserProfileUpdate
|
||||||
|
from src.schemas.user.user_config import UserConfigResponse, UserConfigUpdate
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"UserConfigResponse",
|
||||||
|
"UserConfigUpdate",
|
||||||
|
"UserProfileResponse",
|
||||||
|
"UserProfileUpdate",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1 +1,25 @@
|
|||||||
"""Config module."""
|
"""Config profile services module."""
|
||||||
|
|
||||||
|
from src.services.config.config_profile_resolver import (
|
||||||
|
ConfigProfileCycleError,
|
||||||
|
ConfigProfileNotFoundError,
|
||||||
|
ResolvedMount,
|
||||||
|
ResolvedProfile,
|
||||||
|
apply_resolved_profile,
|
||||||
|
check_include_cycle,
|
||||||
|
expand_container_path,
|
||||||
|
resolve_profile,
|
||||||
|
resolved_profile_to_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ConfigProfileCycleError",
|
||||||
|
"ConfigProfileNotFoundError",
|
||||||
|
"ResolvedMount",
|
||||||
|
"ResolvedProfile",
|
||||||
|
"apply_resolved_profile",
|
||||||
|
"check_include_cycle",
|
||||||
|
"expand_container_path",
|
||||||
|
"resolve_profile",
|
||||||
|
"resolved_profile_to_dict",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -1,181 +1,13 @@
|
|||||||
"""Docker service for managing tool instances."""
|
"""Docker container runtime queries and network management."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from collections import Counter
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def get_container_id(instance_name: str) -> str | None:
|
||||||
"""Get the container ID for a compose service.
|
"""Get the container ID for a compose service.
|
||||||
|
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
"""Clean tunnel service using cloudflared containers on the backend network.
|
"""Cloudflare tunnel management using cloudflared Docker containers.
|
||||||
|
|
||||||
Design:
|
Each tunnel runs as a Docker container on the same 'backend' network as the API.
|
||||||
- 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
|
||||||
- cloudflared connects to the tool container by its Docker Compose service name
|
|
||||||
(e.g. http://code-server-headquarter-34837cd3:8443).
|
(e.g. http://code-server-headquarter-34837cd3:8443).
|
||||||
- This avoids host port conflicts and DNS resolution issues.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -12,7 +10,7 @@ import re
|
|||||||
import subprocess
|
import subprocess
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from src.services.docker import get_backend_network_name
|
from src.services.docker.container import get_backend_network_name
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -55,7 +53,7 @@ def _cleanup_stale_tunnel(tunnel_name: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_container_logs(tunnel_name: str) -> tuple[str, str]:
|
def _get_tunnel_logs(tunnel_name: str) -> tuple[str, str]:
|
||||||
"""Get stdout and stderr logs from a container."""
|
"""Get stdout and stderr logs from a container."""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "logs", tunnel_name],
|
["docker", "logs", tunnel_name],
|
||||||
@@ -65,7 +63,7 @@ def _get_container_logs(tunnel_name: str) -> tuple[str, str]:
|
|||||||
return result.stdout, result.stderr
|
return result.stdout, result.stderr
|
||||||
|
|
||||||
|
|
||||||
def _get_container_exit_code(tunnel_name: str) -> int | None:
|
def _get_tunnel_exit_code(tunnel_name: str) -> int | None:
|
||||||
"""Get exit code of a container if it has exited."""
|
"""Get exit code of a container if it has exited."""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "inspect", "-f", "{{.State.ExitCode}}", tunnel_name],
|
["docker", "inspect", "-f", "{{.State.ExitCode}}", tunnel_name],
|
||||||
@@ -141,7 +139,7 @@ def start_tunnel(
|
|||||||
combined_logs = ""
|
combined_logs = ""
|
||||||
|
|
||||||
while __import__("time").time() - start_time < timeout:
|
while __import__("time").time() - start_time < timeout:
|
||||||
stdout, stderr = _get_container_logs(tunnel_name)
|
stdout, stderr = _get_tunnel_logs(tunnel_name)
|
||||||
combined_logs = stdout + "\n" + stderr
|
combined_logs = stdout + "\n" + stderr
|
||||||
|
|
||||||
match = url_pattern.search(combined_logs)
|
match = url_pattern.search(combined_logs)
|
||||||
@@ -150,7 +148,7 @@ def start_tunnel(
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Check if container exited early
|
# Check if container exited early
|
||||||
exit_code = _get_container_exit_code(tunnel_name)
|
exit_code = _get_tunnel_exit_code(tunnel_name)
|
||||||
if exit_code is not None and exit_code != 0:
|
if exit_code is not None and exit_code != 0:
|
||||||
_cleanup_stale_tunnel(tunnel_name)
|
_cleanup_stale_tunnel(tunnel_name)
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -161,9 +159,9 @@ def start_tunnel(
|
|||||||
__import__("time").sleep(0.5)
|
__import__("time").sleep(0.5)
|
||||||
|
|
||||||
if not url:
|
if not url:
|
||||||
stdout, stderr = _get_container_logs(tunnel_name)
|
stdout, stderr = _get_tunnel_logs(tunnel_name)
|
||||||
combined_logs = stdout + "\n" + stderr
|
combined_logs = stdout + "\n" + stderr
|
||||||
exit_code = _get_container_exit_code(tunnel_name)
|
exit_code = _get_tunnel_exit_code(tunnel_name)
|
||||||
|
|
||||||
_cleanup_stale_tunnel(tunnel_name)
|
_cleanup_stale_tunnel(tunnel_name)
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -1 +1,19 @@
|
|||||||
"""Git module."""
|
"""Git services module."""
|
||||||
|
|
||||||
|
from src.services.git.clone import (
|
||||||
|
check_dirty_state,
|
||||||
|
clone_repository,
|
||||||
|
remove_clone_directory,
|
||||||
|
)
|
||||||
|
from src.services.git.git_operations import Commit, GitOperations, GitStatus
|
||||||
|
from src.services.git.git_service import GitService
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"check_dirty_state",
|
||||||
|
"clone_repository",
|
||||||
|
"remove_clone_directory",
|
||||||
|
"Commit",
|
||||||
|
"GitOperations",
|
||||||
|
"GitStatus",
|
||||||
|
"GitService",
|
||||||
|
]
|
||||||
|
|||||||
@@ -1 +1,7 @@
|
|||||||
"""Instance module."""
|
"""Instance lifecycle services module."""
|
||||||
|
|
||||||
|
from src.services.instance.event_bus import InstanceEventBus
|
||||||
|
from src.services.instance.health_monitor import HealthMonitor
|
||||||
|
from src.services.instance.lifecycle_hooks import publish_lifecycle_event
|
||||||
|
|
||||||
|
__all__ = ["InstanceEventBus", "HealthMonitor", "publish_lifecycle_event"]
|
||||||
|
|||||||
+4
-4
@@ -12,11 +12,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
from src.models import HealthCheck
|
from src.models import HealthCheck
|
||||||
from src.models import ToolInstance
|
from src.models import ToolInstance
|
||||||
from src.services.correlation import get_correlation_id
|
from src.services.shared.correlation import get_correlation_id
|
||||||
from src.services.docker import get_container_status
|
from src.services.docker import get_container_status
|
||||||
from src.services.tunnel import check_tunnel_health
|
from src.services.shared.tunnel import check_tunnel_health
|
||||||
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload
|
||||||
from src.services.notification_service import notification_service
|
from src.services.shared.notification_service import notification_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
+3
-3
@@ -8,9 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from src.models import InstanceEvent
|
from src.models import InstanceEvent
|
||||||
from src.models import ToolInstance
|
from src.models import ToolInstance
|
||||||
from src.services.correlation import get_correlation_id
|
from src.services.shared.correlation import get_correlation_id
|
||||||
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload
|
||||||
from src.services.notification_service import notification_service
|
from src.services.shared.notification_service import notification_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1 +1,51 @@
|
|||||||
"""Shared module."""
|
"""Shared services module."""
|
||||||
|
|
||||||
|
from src.services.shared.correlation import CorrelationIdMiddleware, get_correlation_id
|
||||||
|
from src.services.shared.file_service import FileEntry, FileService
|
||||||
|
from src.services.shared.notification_service import NotificationService
|
||||||
|
from src.services.shared.permission_fixer import (
|
||||||
|
PermissionFixError,
|
||||||
|
apply_mount_permissions,
|
||||||
|
apply_ssh_permissions,
|
||||||
|
check_root_user_available,
|
||||||
|
)
|
||||||
|
from src.services.shared.readiness_probe import execute_probe
|
||||||
|
from src.services.shared.ssh_keys import (
|
||||||
|
cleanup_ssh_key_files,
|
||||||
|
prepare_ssh_key_files,
|
||||||
|
write_ssh_config,
|
||||||
|
)
|
||||||
|
from src.services.shared.tunnel import (
|
||||||
|
check_tunnel_health,
|
||||||
|
recreate_tunnel,
|
||||||
|
start_tunnel,
|
||||||
|
stop_tunnel,
|
||||||
|
)
|
||||||
|
from src.services.shared.workspace_manager import (
|
||||||
|
SyncResult,
|
||||||
|
WorkspaceHasInstancesError,
|
||||||
|
WorkspaceManager,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CorrelationIdMiddleware",
|
||||||
|
"FileEntry",
|
||||||
|
"FileService",
|
||||||
|
"NotificationService",
|
||||||
|
"PermissionFixError",
|
||||||
|
"SyncResult",
|
||||||
|
"WorkspaceHasInstancesError",
|
||||||
|
"WorkspaceManager",
|
||||||
|
"apply_mount_permissions",
|
||||||
|
"apply_ssh_permissions",
|
||||||
|
"check_root_user_available",
|
||||||
|
"check_tunnel_health",
|
||||||
|
"cleanup_ssh_key_files",
|
||||||
|
"execute_probe",
|
||||||
|
"get_correlation_id",
|
||||||
|
"prepare_ssh_key_files",
|
||||||
|
"recreate_tunnel",
|
||||||
|
"start_tunnel",
|
||||||
|
"stop_tunnel",
|
||||||
|
"write_ssh_config",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Tunnel service — re-exported from docker.tunnel for backward compatibility."""
|
||||||
|
|
||||||
|
from src.services.docker.tunnel import ( # noqa: F401
|
||||||
|
check_tunnel_health,
|
||||||
|
recreate_tunnel,
|
||||||
|
start_tunnel,
|
||||||
|
stop_tunnel,
|
||||||
|
)
|
||||||
+2
-2
@@ -15,8 +15,8 @@ from typing import TYPE_CHECKING
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from src.models import Workspace
|
from src.models import Workspace
|
||||||
from src.services.git_service import GitService
|
from src.services.git.git_service import GitService
|
||||||
from src.services.ssh_keys import _get_fernet
|
from src.services.shared.ssh_keys import _get_fernet
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -1 +1,9 @@
|
|||||||
"""Terminal module."""
|
"""Terminal services module."""
|
||||||
|
|
||||||
|
from src.services.terminal.terminal_manager import (
|
||||||
|
MaxSessionsExceededError,
|
||||||
|
TerminalManager,
|
||||||
|
)
|
||||||
|
from src.services.terminal.terminal_session import TerminalSession
|
||||||
|
|
||||||
|
__all__ = ["MaxSessionsExceededError", "TerminalManager", "TerminalSession"]
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|||||||
|
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
from src.models import TerminalSessionModel
|
from src.models import TerminalSessionModel
|
||||||
from src.services.terminal_session import TerminalSession
|
from src.services.terminal.terminal_session import TerminalSession
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
listToolTypes,
|
listToolTypes,
|
||||||
updateToolType,
|
updateToolType,
|
||||||
validateToolType,
|
validateToolType,
|
||||||
} from "../api/tool_types";
|
} from "../api/tool-types";
|
||||||
|
|
||||||
const mockGet = vi.fn();
|
const mockGet = vi.fn();
|
||||||
const mockPost = vi.fn();
|
const mockPost = vi.fn();
|
||||||
@@ -10,11 +10,11 @@ import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
|||||||
import { EventProvider } from "../state/events";
|
import { EventProvider } from "../state/events";
|
||||||
import { ToastProvider } from "../state/toast";
|
import { ToastProvider } from "../state/toast";
|
||||||
import { NotificationProvider } from "../state/notifications";
|
import { NotificationProvider } from "../state/notifications";
|
||||||
import { EventToastBridge } from "./event-toast-bridge";
|
import { EventToastBridge } from "./features/notification/event-toast-bridge";
|
||||||
import { NotificationCenter } from "./notification-center";
|
import { NotificationCenter } from "./features/notification/notification-center";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import { MobileNav } from "./mobile-nav";
|
import { MobileNav } from "./features/mobile/mobile-nav";
|
||||||
import { StartToolFAB } from "./start-tool-fab";
|
import { StartToolFAB } from "./features/tool/start-tool-fab";
|
||||||
import type { IconName } from "../utils/icons";
|
import type { IconName } from "../utils/icons";
|
||||||
|
|
||||||
const NAV_ITEMS: {
|
const NAV_ITEMS: {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "../../icon";
|
||||||
|
|
||||||
interface CommitDialogProps {
|
interface CommitDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { commitChanges } from "../api/git_repositories";
|
import { commitChanges } from "../../../api/git-repositories";
|
||||||
|
|
||||||
interface CommitPanelProps {
|
interface CommitPanelProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
+7
-7
@@ -1,12 +1,12 @@
|
|||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { apiClient } from "../api/client";
|
import { apiClient } from "../../../api/client";
|
||||||
import { useAuth } from "../state/auth";
|
import { useAuth } from "../../../state/auth";
|
||||||
import { CodeEditor } from "../components/code-editor";
|
import { CodeEditor } from "../../code-editor";
|
||||||
import { CommitDialog } from "../components/commit-dialog";
|
import { CommitDialog } from "./commit-dialog";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../../icon";
|
||||||
import { SyntaxHighlighter } from "../components/syntax-highlighter";
|
import { SyntaxHighlighter } from "../../syntax-highlighter";
|
||||||
import { detectLanguage } from "../utils/language";
|
import { detectLanguage } from "../../../utils/language";
|
||||||
|
|
||||||
interface FileEditorProps {
|
interface FileEditorProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "../../icon";
|
||||||
import { validateGitUrl } from "../api/config_profiles";
|
import { validateGitUrl } from "../../../api/config_profiles";
|
||||||
import type { GitMount, GitMountMapping } from "../api/config_profiles";
|
import type { GitMount, GitMountMapping } from "../../../api/config_profiles";
|
||||||
|
|
||||||
interface GitMountEditorProps {
|
interface GitMountEditorProps {
|
||||||
mounts: GitMount[];
|
mounts: GitMount[];
|
||||||
+2
-2
@@ -8,8 +8,8 @@ import {
|
|||||||
pullRepository,
|
pullRepository,
|
||||||
pushRepository,
|
pushRepository,
|
||||||
type GitStatus,
|
type GitStatus,
|
||||||
} from "../api/git_repositories";
|
} from "../../../api/git-repositories";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "../../icon";
|
||||||
import { MergeDialog } from "./merge-dialog";
|
import { MergeDialog } from "./merge-dialog";
|
||||||
|
|
||||||
interface GitToolbarProps {
|
interface GitToolbarProps {
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { mergeBranches } from "../api/git_repositories";
|
import { mergeBranches } from "../../../api/git-repositories";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "../../icon";
|
||||||
|
|
||||||
interface MergeDialogProps {
|
interface MergeDialogProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "../../icon";
|
||||||
import type { IconName } from "./icon";
|
import type { IconName } from "../../icon";
|
||||||
|
|
||||||
export interface MobileActionSheetItem {
|
export interface MobileActionSheetItem {
|
||||||
id: string;
|
id: string;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { Icon } from "./icon";
|
import { Icon } from "../../icon";
|
||||||
|
|
||||||
interface Field {
|
interface Field {
|
||||||
label: string;
|
label: string;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { Icon } from "./icon";
|
import { Icon } from "../../icon";
|
||||||
|
|
||||||
interface MobileFABProps {
|
interface MobileFABProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import { Icon } from "./icon";
|
import { Icon } from "../../icon";
|
||||||
import type { IconName } from "../utils/icons";
|
import type { IconName } from "../../../utils/icons";
|
||||||
|
|
||||||
interface MobileListItem {
|
interface MobileListItem {
|
||||||
id: string;
|
id: string;
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { NavLink, useLocation } from "react-router-dom";
|
import { NavLink, useLocation } from "react-router-dom";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "../../icon";
|
||||||
import { ToolsBottomSheet } from "./tools-bottom-sheet";
|
import { ToolsBottomSheet } from "../tool/tools-bottom-sheet";
|
||||||
import type { IconName } from "../utils/icons";
|
import type { IconName } from "../../../utils/icons";
|
||||||
|
|
||||||
interface MobileNavProps {
|
interface MobileNavProps {
|
||||||
sessionCount?: number;
|
sessionCount?: number;
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
import { useMobileViewport } from "../../../hooks/use-mobile-viewport";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "../../icon";
|
||||||
|
|
||||||
interface MobilePageHeaderProps {
|
interface MobilePageHeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "../../icon";
|
||||||
|
|
||||||
interface MobileTerminalHeaderProps {
|
interface MobileTerminalHeaderProps {
|
||||||
instanceName?: string;
|
instanceName?: string;
|
||||||
+7
-7
@@ -1,12 +1,12 @@
|
|||||||
import React, { useState, useCallback } from "react";
|
import React, { useState, useCallback } from "react";
|
||||||
import { TerminalComponent } from "./terminal";
|
import { TerminalComponent } from "../terminal/terminal";
|
||||||
import { MobileTerminalHeader } from "./mobile-terminal-header";
|
import { MobileTerminalHeader } from "./mobile-terminal-header";
|
||||||
import { SpecialKeysStrip } from "./special-keys-strip";
|
import { SpecialKeysStrip } from "../terminal/special-keys-strip";
|
||||||
import { SpecialKeysPanel } from "./special-keys-panel";
|
import { SpecialKeysPanel } from "../terminal/special-keys-panel";
|
||||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
import { useMobileViewport } from "../../../hooks/use-mobile-viewport";
|
||||||
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
|
import { useVirtualKeyboard } from "../../../hooks/use-virtual-keyboard";
|
||||||
import { useAutoHide } from "../hooks/use-auto-hide";
|
import { useAutoHide } from "../../../hooks/use-auto-hide";
|
||||||
import type { ModifierKey } from "../hooks/use-special-keys";
|
import type { ModifierKey } from "../../../hooks/use-special-keys";
|
||||||
|
|
||||||
interface MobileTerminalWrapperProps {
|
interface MobileTerminalWrapperProps {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
+3
-3
@@ -2,15 +2,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|||||||
import { render, act } from "@testing-library/react";
|
import { render, act } from "@testing-library/react";
|
||||||
import { EventToastBridge } from "./event-toast-bridge";
|
import { EventToastBridge } from "./event-toast-bridge";
|
||||||
import { useEventContext } from "../state/events";
|
import { useEventContext } from "../state/events";
|
||||||
import { getUserConfig } from "../api/settings";
|
import { getUserConfig } from "../../../api/settings";
|
||||||
import { handleEventToast } from "./toast-rules";
|
import { handleEventToast } from "./toast-rules";
|
||||||
import type { InstanceEventPayload } from "../types/events";
|
import type { InstanceEventPayload } from "../../../types/events";
|
||||||
|
|
||||||
vi.mock("../state/events", () => ({
|
vi.mock("../state/events", () => ({
|
||||||
useEventContext: vi.fn(),
|
useEventContext: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../api/settings", () => ({
|
vi.mock("../../../api/settings", () => ({
|
||||||
getUserConfig: vi.fn(),
|
getUserConfig: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useEventContext } from "../state/events";
|
import { useEventContext } from "../../../state/events";
|
||||||
import {
|
import {
|
||||||
handleEventToast,
|
handleEventToast,
|
||||||
mapEventToCategory,
|
mapEventToCategory,
|
||||||
mapEventToSeverity,
|
mapEventToSeverity,
|
||||||
} from "./toast-rules";
|
} from "../../toast-rules";
|
||||||
import { getUserConfig } from "../api/settings";
|
import { getUserConfig } from "../../../api/settings";
|
||||||
import type { UserConfig } from "../api/settings";
|
import type { UserConfig } from "../../../api/settings";
|
||||||
|
|
||||||
interface ToastConfig {
|
interface ToastConfig {
|
||||||
notification_toast_level: string;
|
notification_toast_level: string;
|
||||||
+4
-4
@@ -3,7 +3,7 @@ import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
|||||||
import { NotificationCenter } from "./notification-center";
|
import { NotificationCenter } from "./notification-center";
|
||||||
import { NotificationProvider } from "../state/notifications";
|
import { NotificationProvider } from "../state/notifications";
|
||||||
|
|
||||||
vi.mock("../api/notifications", () => ({
|
vi.mock("../../../api/notifications", () => ({
|
||||||
getNotifications: vi.fn(),
|
getNotifications: vi.fn(),
|
||||||
getUnreadCount: vi.fn(),
|
getUnreadCount: vi.fn(),
|
||||||
markNotificationRead: vi.fn(),
|
markNotificationRead: vi.fn(),
|
||||||
@@ -12,7 +12,7 @@ vi.mock("../api/notifications", () => ({
|
|||||||
clearAllNotifications: vi.fn(),
|
clearAllNotifications: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { getNotifications, getUnreadCount } from "../api/notifications";
|
import { getNotifications, getUnreadCount } from "../../../api/notifications";
|
||||||
|
|
||||||
const mockedGetNotifications = vi.mocked(getNotifications);
|
const mockedGetNotifications = vi.mocked(getNotifications);
|
||||||
const mockedGetUnreadCount = vi.mocked(getUnreadCount);
|
const mockedGetUnreadCount = vi.mocked(getUnreadCount);
|
||||||
@@ -142,7 +142,7 @@ describe("NotificationCenter", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: /mark all as read/i }));
|
fireEvent.click(screen.getByRole("button", { name: /mark all as read/i }));
|
||||||
|
|
||||||
const { markAllNotificationsRead: mockMarkAll } = await import(
|
const { markAllNotificationsRead: mockMarkAll } = await import(
|
||||||
"../api/notifications"
|
"../../../api/notifications"
|
||||||
);
|
);
|
||||||
expect(vi.mocked(mockMarkAll)).toHaveBeenCalled();
|
expect(vi.mocked(mockMarkAll)).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -162,7 +162,7 @@ describe("NotificationCenter", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: /clear all/i }));
|
fireEvent.click(screen.getByRole("button", { name: /clear all/i }));
|
||||||
|
|
||||||
const { clearAllNotifications: mockClearAll } = await import(
|
const { clearAllNotifications: mockClearAll } = await import(
|
||||||
"../api/notifications"
|
"../../../api/notifications"
|
||||||
);
|
);
|
||||||
expect(vi.mocked(mockClearAll)).toHaveBeenCalled();
|
expect(vi.mocked(mockClearAll)).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useNotifications } from "../hooks/use-notifications";
|
import { useNotifications } from "../../../hooks/use-notifications";
|
||||||
import { NotificationItem } from "./notification-item";
|
import { NotificationItem } from "./notification-item";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "../../icon";
|
||||||
|
|
||||||
interface NotificationCenterProps {
|
interface NotificationCenterProps {
|
||||||
isMobileTerminal?: boolean;
|
isMobileTerminal?: boolean;
|
||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
import { Icon } from "./icon";
|
import { Icon } from "../../icon";
|
||||||
import { formatRelativeTime } from "../utils/time";
|
import { formatRelativeTime } from "../../../utils/time";
|
||||||
import type { NotificationItem as NotificationItemType } from "../api/notifications";
|
import type { NotificationItem as NotificationItemType } from "../../../api/notifications";
|
||||||
|
|
||||||
export interface NotificationItemProps {
|
export interface NotificationItemProps {
|
||||||
notification: NotificationItemType;
|
notification: NotificationItemType;
|
||||||
@@ -8,7 +8,7 @@ export interface NotificationItemProps {
|
|||||||
onDismiss: (id: string) => void;
|
onDismiss: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
import type { IconName } from "../utils/icons";
|
import type { IconName } from "../../../utils/icons";
|
||||||
|
|
||||||
const severityIconMap: Record<string, IconName> = {
|
const severityIconMap: Record<string, IconName> = {
|
||||||
info: "info",
|
info: "info",
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user