Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 703cf1f88b | |||
| 5dc7d44111 | |||
| ee348643f8 | |||
| 05a598812b | |||
| 7515d9106f | |||
| 8c7affc933 | |||
| 2680a8c44a | |||
| 1021d61be3 | |||
| 7224afafd1 | |||
| 020f832eed | |||
| 7fe2790199 | |||
| 38c51ed95e | |||
| 37ccaa4fdc | |||
| 8816ee02ce | |||
| 6104f592eb | |||
| 0591b00ded | |||
| 0127d283a6 | |||
| 2757ef3b4f | |||
| ab55da280c | |||
| 4e076c36d2 | |||
| c6d62f84da | |||
| 8a0d82f49b | |||
| 0c74997cfe | |||
| fc72c5f6e9 |
+5
-4
@@ -48,8 +48,9 @@ apps/web/dist/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
/.stoneforge/.worktrees/
|
||||||
# Local runtime state
|
# Pi / agent cache
|
||||||
.atl/
|
|
||||||
.pi/
|
.pi/
|
||||||
swap-pane
|
.atl/
|
||||||
|
.sisyphus/
|
||||||
|
.pi-lens/
|
||||||
|
|||||||
@@ -90,13 +90,6 @@ Before completion, report:
|
|||||||
|
|
||||||
Do not claim completion without verification evidence.
|
Do not claim completion without verification evidence.
|
||||||
|
|
||||||
## Git branch policy
|
|
||||||
|
|
||||||
- **Default working branch:** `dev` — all commits and pushes target `dev` unless the user explicitly requests otherwise.
|
|
||||||
- `main` is the stable/production branch; merge to `main` only when explicitly instructed.
|
|
||||||
- After committing, push to `origin/dev`.
|
|
||||||
- If `dev` does not exist locally, create it from `main` or fetch it from origin.
|
|
||||||
|
|
||||||
## Git workflow
|
## Git workflow
|
||||||
|
|
||||||
### Branching strategy
|
### Branching strategy
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **User Settings** - Theme selection, git identity, and preference management
|
- **User Settings** - Theme selection, git identity, and preference management
|
||||||
- **SSH Key Management** - Ed25519 key generation with secure storage
|
- **SSH Key Management** - Ed25519 key generation with secure storage
|
||||||
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
|
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
|
||||||
- **Config Profiles** - User-owned profile CRUD with includes, mounts, path validation, cycle detection, and default profile selection
|
|
||||||
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
|
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Alembic cache
|
||||||
|
alembic/versions/__pycache__/
|
||||||
|
|
||||||
|
# Pi lens cache
|
||||||
|
.pi-lens/
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Scripts not needed in container
|
||||||
|
scripts/
|
||||||
@@ -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"]
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""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"]
|
||||||
@@ -0,0 +1,842 @@
|
|||||||
|
"""Config profile API endpoints."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models import ConfigProfile, ConfigProfileInclude
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models import ToolType
|
||||||
|
from src.models import UserConfig
|
||||||
|
from src.schemas.config import (
|
||||||
|
ConfigProfileCreate,
|
||||||
|
ConfigProfileIncludeUpdate,
|
||||||
|
ConfigProfileResponse,
|
||||||
|
ConfigProfileUpdate,
|
||||||
|
DefaultProfilesUpdate,
|
||||||
|
ValidateGitUrlRequest,
|
||||||
|
ValidateGitUrlResponse,
|
||||||
|
)
|
||||||
|
from src.services.config.config_profile_resolver import (
|
||||||
|
ConfigProfileCycleError,
|
||||||
|
check_include_cycle,
|
||||||
|
resolve_profile,
|
||||||
|
resolved_profile_to_dict,
|
||||||
|
)
|
||||||
|
from src.utils.git_url_parser import parse_git_url
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
||||||
|
|
||||||
|
MAX_PROFILE_SIZE_MB = 10
|
||||||
|
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_profile_size(data: dict) -> int:
|
||||||
|
"""Calculate approximate serialized size of profile data."""
|
||||||
|
total = 0
|
||||||
|
for key, value in data.get("env_vars", {}).items():
|
||||||
|
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
||||||
|
for key, value in data.get("runtime_hints", {}).items():
|
||||||
|
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
||||||
|
for mount in data.get("mounts", []):
|
||||||
|
total += len(str(mount.get("target", "")).encode("utf-8"))
|
||||||
|
total += len(str(mount.get("mode", "")).encode("utf-8"))
|
||||||
|
for path, content in mount.get("files", {}).items():
|
||||||
|
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
||||||
|
for path, content in data.get("files", {}).items():
|
||||||
|
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_profile_with_includes(
|
||||||
|
session: AsyncSession, profile_id: uuid.UUID
|
||||||
|
) -> ConfigProfile | None:
|
||||||
|
"""Fetch a profile with includes eagerly loaded."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.id == profile_id)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_access(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID | None = None,
|
||||||
|
tool_type_id: uuid.UUID | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Verify user has access to referenced project and tool type."""
|
||||||
|
if project_id is not None:
|
||||||
|
project = await session.get(Project, project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
||||||
|
)
|
||||||
|
# Add ownership check if needed; for now just verify existence
|
||||||
|
if tool_type_id is not None:
|
||||||
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
|
if tool_type is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_git_mounts(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
git_mounts: list[Any],
|
||||||
|
project_id: uuid.UUID | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Validate git mount URLs.
|
||||||
|
|
||||||
|
Simply checks that remote_url looks like a valid git URL.
|
||||||
|
Actual clone validation happens at instance startup time.
|
||||||
|
"""
|
||||||
|
for mount in git_mounts:
|
||||||
|
remote_url = mount.get("remote_url")
|
||||||
|
if not remote_url:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Git mount missing remote_url",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid git URL: {remote_url}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_to_response(
|
||||||
|
profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(profile.id),
|
||||||
|
"user_id": str(profile.user_id),
|
||||||
|
"name": profile.name,
|
||||||
|
"description": profile.description,
|
||||||
|
"project_id": str(profile.project_id) if profile.project_id else None,
|
||||||
|
"tool_type_id": str(profile.tool_type_id) if profile.tool_type_id else None,
|
||||||
|
"env_vars": profile.env_vars or {},
|
||||||
|
"runtime_hints": profile.runtime_hints or {},
|
||||||
|
"mounts": profile.mounts or [],
|
||||||
|
"git_mounts": profile.git_mounts or [],
|
||||||
|
"files": profile.files or {},
|
||||||
|
"is_default": profile.is_default,
|
||||||
|
"includes": [
|
||||||
|
{
|
||||||
|
"id": str(inc.id),
|
||||||
|
"included_profile_id": str(inc.included_profile_id),
|
||||||
|
"order_index": inc.order_index,
|
||||||
|
}
|
||||||
|
for inc in (includes or profile.includes)
|
||||||
|
],
|
||||||
|
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||||
|
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ConfigProfileResponse])
|
||||||
|
async def list_config_profiles(
|
||||||
|
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
||||||
|
tool_type_id: str | None = Query(
|
||||||
|
None, description="Filter by tool type compatibility"
|
||||||
|
),
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""List config profiles, optionally filtered by compatibility."""
|
||||||
|
user_uuid = current_user_id
|
||||||
|
query = (
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.user_id == user_uuid)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
|
||||||
|
if project_id or tool_type_id:
|
||||||
|
# Compatibility filter: include portable profiles and matching scoped profiles
|
||||||
|
project_uuid = uuid.UUID(project_id) if project_id else None
|
||||||
|
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
conditions: list = []
|
||||||
|
# Portable profiles (no project, no tool)
|
||||||
|
conditions.append(
|
||||||
|
(ConfigProfile.project_id.is_(None))
|
||||||
|
& (ConfigProfile.tool_type_id.is_(None))
|
||||||
|
)
|
||||||
|
if project_uuid:
|
||||||
|
# Profiles matching this project (with or without tool)
|
||||||
|
conditions.append(ConfigProfile.project_id == project_uuid)
|
||||||
|
if tool_uuid:
|
||||||
|
# Profiles matching this tool (with or without project)
|
||||||
|
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
|
||||||
|
if project_uuid and tool_uuid:
|
||||||
|
# Exact match
|
||||||
|
conditions.append(
|
||||||
|
(ConfigProfile.project_id == project_uuid)
|
||||||
|
& (ConfigProfile.tool_type_id == tool_uuid)
|
||||||
|
)
|
||||||
|
|
||||||
|
query = query.where(or_(*conditions))
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
profiles = result.scalars().all()
|
||||||
|
return [_profile_to_response(p) for p in profiles]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def create_config_profile(
|
||||||
|
data: ConfigProfileCreate,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Create a new config profile."""
|
||||||
|
user_uuid = current_user_id
|
||||||
|
|
||||||
|
# Check for duplicate name
|
||||||
|
existing = await session.execute(
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(
|
||||||
|
ConfigProfile.user_id == user_uuid,
|
||||||
|
ConfigProfile.name == data.name,
|
||||||
|
)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Profile with name '{data.name}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate references
|
||||||
|
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
|
||||||
|
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
|
||||||
|
await _check_access(session, user_uuid, project_uuid, tool_uuid)
|
||||||
|
|
||||||
|
# Validate git mounts reference existing repositories
|
||||||
|
if data.git_mounts:
|
||||||
|
git_mounts_data = [
|
||||||
|
m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts
|
||||||
|
]
|
||||||
|
await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
|
||||||
|
|
||||||
|
# Check size
|
||||||
|
size = _calculate_profile_size(data.model_dump())
|
||||||
|
if size > MAX_PROFILE_SIZE_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = ConfigProfile(
|
||||||
|
user_id=user_uuid,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
project_id=project_uuid,
|
||||||
|
tool_type_id=tool_uuid,
|
||||||
|
env_vars=data.env_vars,
|
||||||
|
runtime_hints=data.runtime_hints,
|
||||||
|
mounts=[m.model_dump() for m in data.mounts],
|
||||||
|
git_mounts=[m.model_dump() for m in data.git_mounts],
|
||||||
|
files=data.files,
|
||||||
|
is_default=data.is_default,
|
||||||
|
)
|
||||||
|
session.add(profile)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Re-fetch with includes to avoid lazy loading issues
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.id == profile.id)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
profile = result.scalar_one()
|
||||||
|
|
||||||
|
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
|
||||||
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
|
||||||
|
async def get_config_profile(
|
||||||
|
profile_id: str,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Get a config profile by ID."""
|
||||||
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
|
||||||
|
async def update_config_profile(
|
||||||
|
profile_id: str,
|
||||||
|
data: ConfigProfileUpdate,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Update a config profile."""
|
||||||
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# Handle name uniqueness
|
||||||
|
if "name" in update_data:
|
||||||
|
existing = await session.execute(
|
||||||
|
select(ConfigProfile).where(
|
||||||
|
ConfigProfile.user_id == profile.user_id,
|
||||||
|
ConfigProfile.name == update_data["name"],
|
||||||
|
ConfigProfile.id != profile.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Profile with name '{update_data['name']}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate references
|
||||||
|
project_uuid = (
|
||||||
|
uuid.UUID(update_data["project_id"])
|
||||||
|
if "project_id" in update_data and update_data["project_id"]
|
||||||
|
else (profile.project_id if "project_id" not in update_data else None)
|
||||||
|
)
|
||||||
|
tool_uuid = (
|
||||||
|
uuid.UUID(update_data["tool_type_id"])
|
||||||
|
if "tool_type_id" in update_data and update_data["tool_type_id"]
|
||||||
|
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
|
||||||
|
)
|
||||||
|
await _check_access(session, profile.user_id, project_uuid, tool_uuid)
|
||||||
|
|
||||||
|
# Validate git mounts reference existing repositories
|
||||||
|
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
|
||||||
|
git_mounts_data = [
|
||||||
|
m.model_dump() if hasattr(m, "model_dump") else m
|
||||||
|
for m in update_data["git_mounts"]
|
||||||
|
]
|
||||||
|
await _validate_git_mounts(
|
||||||
|
session, profile.user_id, git_mounts_data, project_uuid
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check size
|
||||||
|
current_data = _profile_to_response(profile)
|
||||||
|
merged = {**current_data, **update_data}
|
||||||
|
size = _calculate_profile_size(merged)
|
||||||
|
if size > MAX_PROFILE_SIZE_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply updates
|
||||||
|
for field_name, value in update_data.items():
|
||||||
|
if field_name in ("project_id", "tool_type_id"):
|
||||||
|
value = uuid.UUID(value) if value else None
|
||||||
|
elif field_name == "mounts" and value is not None:
|
||||||
|
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
|
||||||
|
elif field_name == "git_mounts" and value is not None:
|
||||||
|
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
|
||||||
|
setattr(profile, field_name, value)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Re-fetch with includes to avoid lazy loading issues
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.id == profile.id)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
profile = result.scalar_one()
|
||||||
|
|
||||||
|
logger.debug("Updated config profile %s", profile.id)
|
||||||
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_config_profile(
|
||||||
|
profile_id: str,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Delete a config profile."""
|
||||||
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.delete(profile)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.debug("Deleted config profile %s", profile_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
|
||||||
|
async def update_profile_includes(
|
||||||
|
profile_id: str,
|
||||||
|
data: ConfigProfileIncludeUpdate,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Update the ordered includes for a config profile."""
|
||||||
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate all included profiles exist and belong to the user
|
||||||
|
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
||||||
|
for inc_uuid in included_uuids:
|
||||||
|
inc_profile = await session.get(ConfigProfile, inc_uuid)
|
||||||
|
if inc_profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Included profile not found: {inc_uuid}",
|
||||||
|
)
|
||||||
|
if inc_profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Not authorized to include profile: {inc_uuid}",
|
||||||
|
)
|
||||||
|
if inc_uuid == profile.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Profile cannot include itself",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for cycles
|
||||||
|
cycle = await check_include_cycle(session, profile.id, None)
|
||||||
|
if cycle is None and included_uuids:
|
||||||
|
# Check each new include would not create a cycle
|
||||||
|
for inc_uuid in included_uuids:
|
||||||
|
cycle = await check_include_cycle(session, profile.id, inc_uuid)
|
||||||
|
if cycle is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if cycle is not None:
|
||||||
|
cycle_str = " -> ".join(str(c) for c in cycle)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Include cycle detected: {cycle_str}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove existing includes
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfileInclude).where(
|
||||||
|
ConfigProfileInclude.profile_id == profile.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for existing in result.scalars().all():
|
||||||
|
await session.delete(existing)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
# Add new includes
|
||||||
|
for order_index, inc_uuid in enumerate(included_uuids):
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
profile_id=profile.id,
|
||||||
|
included_profile_id=inc_uuid,
|
||||||
|
order_index=order_index,
|
||||||
|
)
|
||||||
|
session.add(include)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Re-fetch profile (includes loaded separately due to SQLite async issue)
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile).where(ConfigProfile.id == profile.id)
|
||||||
|
)
|
||||||
|
profile = result.scalar_one()
|
||||||
|
|
||||||
|
inc_result = await session.execute(
|
||||||
|
select(ConfigProfileInclude).where(
|
||||||
|
ConfigProfileInclude.profile_id == profile.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
direct_includes = inc_result.scalars().all()
|
||||||
|
|
||||||
|
logger.debug("Updated includes for config profile %s", profile.id)
|
||||||
|
return _profile_to_response(profile, list(direct_includes))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{profile_id}/preview")
|
||||||
|
async def preview_config_profile(
|
||||||
|
profile_id: str,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Preview the resolved output of a config profile."""
|
||||||
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved = await resolve_profile(session, profile.id)
|
||||||
|
except ConfigProfileCycleError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
return resolved_profile_to_dict(resolved)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/defaults/resolve")
|
||||||
|
async def resolve_default_profile(
|
||||||
|
project_id: str = Query(..., description="Project ID"),
|
||||||
|
tool_type_id: str = Query(..., description="Tool type ID"),
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Resolve the default config profile for a project/tool combination.
|
||||||
|
|
||||||
|
Selects by specificity:
|
||||||
|
1. project+tool explicit default
|
||||||
|
2. project explicit default
|
||||||
|
3. tool explicit default
|
||||||
|
4. global/user explicit default
|
||||||
|
5. first created compatible profile
|
||||||
|
6. none (returns null)
|
||||||
|
"""
|
||||||
|
user_uuid = current_user_id
|
||||||
|
project_uuid = uuid.UUID(project_id)
|
||||||
|
tool_uuid = uuid.UUID(tool_type_id)
|
||||||
|
|
||||||
|
# Fetch all compatible profiles ordered by created_at
|
||||||
|
query = (
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.user_id == user_uuid)
|
||||||
|
.where(
|
||||||
|
(ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None))
|
||||||
|
| (ConfigProfile.project_id == project_uuid)
|
||||||
|
| (ConfigProfile.tool_type_id == tool_uuid)
|
||||||
|
| (
|
||||||
|
(ConfigProfile.project_id == project_uuid)
|
||||||
|
& (ConfigProfile.tool_type_id == tool_uuid)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(ConfigProfile.created_at)
|
||||||
|
)
|
||||||
|
result = await session.execute(query)
|
||||||
|
profiles = result.scalars().all()
|
||||||
|
|
||||||
|
if not profiles:
|
||||||
|
return {"profile_id": None, "profile_name": None}
|
||||||
|
|
||||||
|
# Check explicit defaults by specificity
|
||||||
|
explicit_defaults = [p for p in profiles if p.is_default]
|
||||||
|
|
||||||
|
# Most specific: project+tool
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id == project_uuid and p.tool_type_id == tool_uuid:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
# Next: project only
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id == project_uuid and p.tool_type_id is None:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
# Next: tool only
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id is None and p.tool_type_id == tool_uuid:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
# Next: global/user (no project, no tool)
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id is None and p.tool_type_id is None:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
# Fall back to first created compatible profile
|
||||||
|
first = profiles[0]
|
||||||
|
return {"profile_id": str(first.id), "profile_name": first.name}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Default profile management
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_create_user_config(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> UserConfig:
|
||||||
|
"""Get existing user config or create a new one."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||||
|
)
|
||||||
|
user_config = result.scalar_one_or_none()
|
||||||
|
if user_config is None:
|
||||||
|
user_config = UserConfig(user_id=user_id, config={})
|
||||||
|
session.add(user_config)
|
||||||
|
return user_config
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_default_profiles(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
default_profiles: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
"""Validate that all profile IDs in default_profiles belong to the user."""
|
||||||
|
for tool_type_id, profile_id_str in default_profiles.items():
|
||||||
|
try:
|
||||||
|
profile_uuid = uuid.UUID(profile_id_str)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid profile ID for tool type {tool_type_id}: {profile_id_str}",
|
||||||
|
)
|
||||||
|
profile = await session.get(ConfigProfile, profile_uuid)
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Profile not found: {profile_id_str}",
|
||||||
|
)
|
||||||
|
if profile.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Profile does not belong to user: {profile_id_str}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/defaults")
|
||||||
|
async def get_default_profiles_endpoint(
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get all default profile mappings for the current user."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||||
|
)
|
||||||
|
user_config = result.scalar_one_or_none()
|
||||||
|
return {"default_profiles": user_config.default_profiles if user_config else {}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/defaults")
|
||||||
|
async def set_default_profiles_endpoint(
|
||||||
|
data: DefaultProfilesUpdate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Set default profile mappings for the current user."""
|
||||||
|
await _validate_default_profiles(session, user_id, data.default_profiles)
|
||||||
|
user_config = await _get_or_create_user_config(session, user_id)
|
||||||
|
user_config.config = {
|
||||||
|
**user_config.config,
|
||||||
|
"default_profiles": data.default_profiles,
|
||||||
|
}
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user_config)
|
||||||
|
return {"default_profiles": user_config.default_profiles}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/defaults/{tool_type_id}")
|
||||||
|
async def get_default_profile_for_tool_type_endpoint(
|
||||||
|
tool_type_id: str,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get the default profile ID for a specific tool type."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||||
|
)
|
||||||
|
user_config = result.scalar_one_or_none()
|
||||||
|
profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None
|
||||||
|
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
|
||||||
|
async def validate_git_url(
|
||||||
|
data: ValidateGitUrlRequest,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> ValidateGitUrlResponse:
|
||||||
|
"""Validate a git remote URL and list available branches.
|
||||||
|
|
||||||
|
Parses the URL, suggests corrections for browser URLs, and runs
|
||||||
|
git ls-remote to verify reachability and enumerate branches.
|
||||||
|
"""
|
||||||
|
parse_result = parse_git_url(data.url)
|
||||||
|
original_url = data.url.strip()
|
||||||
|
url_to_check = parse_result.get("base_url") or original_url
|
||||||
|
|
||||||
|
if not url_to_check:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error=parse_result.get("message", "Invalid URL"),
|
||||||
|
error_code=parse_result.get("error_code", "INVALID_URL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# If the URL needed parsing, return suggestion without checking remote
|
||||||
|
if parse_result.get("needs_parsing") and url_to_check != original_url:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
suggested_url=url_to_check,
|
||||||
|
error=parse_result.get("message"),
|
||||||
|
error_code=parse_result.get("error_code", "URL_NEEDS_PARSING"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optional SSH key for private repos
|
||||||
|
env = None
|
||||||
|
key_path = None
|
||||||
|
if data.ssh_key_id:
|
||||||
|
from src.models import SSHKey
|
||||||
|
from src.services.shared.ssh_keys import _get_fernet
|
||||||
|
|
||||||
|
try:
|
||||||
|
ssh_key_uuid = uuid.UUID(data.ssh_key_id)
|
||||||
|
except ValueError:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="Invalid SSH key ID format",
|
||||||
|
error_code="INVALID_SSH_KEY",
|
||||||
|
)
|
||||||
|
|
||||||
|
ssh_key = await session.get(SSHKey, ssh_key_uuid)
|
||||||
|
if ssh_key is None or ssh_key.user_id != current_user_id:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="SSH key not found or not authorized",
|
||||||
|
error_code="SSH_KEY_NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
fernet = _get_fernet()
|
||||||
|
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
|
||||||
|
try:
|
||||||
|
os.write(fd, private_key.encode())
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
os.chmod(key_path, 0o600)
|
||||||
|
env = {
|
||||||
|
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "ls-remote", "--heads", url_to_check],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="Remote repository check timed out",
|
||||||
|
error_code="TIMEOUT",
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="git command not found on server",
|
||||||
|
error_code="GIT_NOT_FOUND",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
stderr = result.stderr.strip()
|
||||||
|
if (
|
||||||
|
"could not resolve" in stderr.lower()
|
||||||
|
or "unable to access" in stderr.lower()
|
||||||
|
):
|
||||||
|
error_msg = "Could not reach repository. Check the URL and network access."
|
||||||
|
error_code = "UNREACHABLE"
|
||||||
|
elif (
|
||||||
|
"authentication" in stderr.lower() or "permission denied" in stderr.lower()
|
||||||
|
):
|
||||||
|
error_msg = (
|
||||||
|
"Authentication failed. Provide an SSH key for private repositories."
|
||||||
|
)
|
||||||
|
error_code = "AUTH_FAILED"
|
||||||
|
else:
|
||||||
|
error_msg = f"Repository not accessible: {stderr[:200]}"
|
||||||
|
error_code = "REMOTE_ERROR"
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error=error_msg,
|
||||||
|
error_code=error_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse branches from ls-remote output
|
||||||
|
branches: list[str] = []
|
||||||
|
default_branch = "main"
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) == 2:
|
||||||
|
ref = parts[1]
|
||||||
|
# refs/heads/branch-name
|
||||||
|
if ref.startswith("refs/heads/"):
|
||||||
|
branch_name = ref[len("refs/heads/") :]
|
||||||
|
branches.append(branch_name)
|
||||||
|
if branch_name in ("main", "master"):
|
||||||
|
default_branch = branch_name
|
||||||
|
|
||||||
|
if not branches:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="No branches found in remote repository",
|
||||||
|
error_code="NO_BRANCHES",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=True,
|
||||||
|
suggested_url=url_to_check if url_to_check != original_url else None,
|
||||||
|
branches=branches,
|
||||||
|
default_branch=default_branch,
|
||||||
|
)
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends
|
||||||
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_current_user, 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 import UserConfig
|
||||||
from src.models.user_config import UserConfig
|
from src.schemas.user import UserConfigResponse, UserConfigUpdate
|
||||||
from src.schemas.user_config import UserConfigResponse, UserConfigUpdate
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_create_config(
|
||||||
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
|
session: AsyncSession, user_id: uuid.UUID
|
||||||
|
) -> UserConfig:
|
||||||
"""Get or create user config record.
|
"""Get or create user config record.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -24,10 +26,12 @@ async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> Us
|
|||||||
Returns:
|
Returns:
|
||||||
The user's config, creating a new one if it doesn't exist.
|
The user's config, creating a new one if it doesn't exist.
|
||||||
"""
|
"""
|
||||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user.id))
|
result = await session.execute(
|
||||||
|
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||||
|
)
|
||||||
config = result.scalar_one_or_none()
|
config = result.scalar_one_or_none()
|
||||||
if config is None:
|
if config is None:
|
||||||
config = UserConfig(user_id=user.id, config={})
|
config = UserConfig(user_id=user_id, config={})
|
||||||
session.add(config)
|
session.add(config)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
@@ -41,7 +45,7 @@ async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> Us
|
|||||||
description="Get the current user's configuration settings.",
|
description="Get the current user's configuration settings.",
|
||||||
)
|
)
|
||||||
async def get_user_config(
|
async def get_user_config(
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> UserConfigResponse:
|
) -> UserConfigResponse:
|
||||||
"""Get the current user's configuration.
|
"""Get the current user's configuration.
|
||||||
@@ -53,7 +57,8 @@ async def get_user_config(
|
|||||||
Returns:
|
Returns:
|
||||||
The user's configuration settings.
|
The user's configuration settings.
|
||||||
"""
|
"""
|
||||||
config = await _get_or_create_config(session, user.id)
|
_user = await _get_user(session, user_id)
|
||||||
|
config = await _get_or_create_config(session, user_id)
|
||||||
return UserConfigResponse.model_validate(config.config)
|
return UserConfigResponse.model_validate(config.config)
|
||||||
|
|
||||||
|
|
||||||
@@ -65,7 +70,7 @@ async def get_user_config(
|
|||||||
)
|
)
|
||||||
async def update_user_config(
|
async def update_user_config(
|
||||||
data: UserConfigUpdate,
|
data: UserConfigUpdate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> UserConfigResponse:
|
) -> UserConfigResponse:
|
||||||
"""Update the current user's configuration.
|
"""Update the current user's configuration.
|
||||||
@@ -78,15 +83,16 @@ async def update_user_config(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated user configuration.
|
The updated user configuration.
|
||||||
"""
|
"""
|
||||||
config = await _get_or_create_config(session, user.id)
|
_user = await _get_user(session, user_id)
|
||||||
|
config = await _get_or_create_config(session, user_id)
|
||||||
|
|
||||||
# Merge updates
|
# Merge updates
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
logger.info("Updating user config for user %s: %s", user.id, update_data)
|
logger.debug("Updating user config for user %s: %s", user_id, update_data)
|
||||||
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
||||||
config.config = {**config.config, **update_data}
|
config.config = {**config.config, **update_data}
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
logger.info("Updated config: %s", config.config)
|
logger.debug("Updated config: %s", config.config)
|
||||||
return UserConfigResponse.model_validate(config.config)
|
return UserConfigResponse.model_validate(config.config)
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
"""Config profile API endpoints."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
|
||||||
from src.models.config_include import ConfigInclude
|
|
||||||
from src.models.config_mount import ConfigMount
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
from src.models.user_config import UserConfig
|
|
||||||
from src.schemas.config_profile import (
|
|
||||||
ConfigIncludeCreate,
|
|
||||||
ConfigIncludeUpdate,
|
|
||||||
ConfigMountCreate,
|
|
||||||
ConfigMountUpdate,
|
|
||||||
ConfigProfileCreate,
|
|
||||||
ConfigProfileUpdate,
|
|
||||||
DefaultProfilesUpdate,
|
|
||||||
)
|
|
||||||
from src.services.config_profiles import (
|
|
||||||
check_duplicate_include,
|
|
||||||
check_duplicate_mount_path,
|
|
||||||
check_duplicate_name,
|
|
||||||
get_default_profile_for_tool_type,
|
|
||||||
get_default_profiles,
|
|
||||||
get_owned_profile,
|
|
||||||
include_to_dict,
|
|
||||||
list_includes_for_profile,
|
|
||||||
list_mounts_for_profile,
|
|
||||||
mount_to_dict,
|
|
||||||
profile_to_dict,
|
|
||||||
set_default_profiles,
|
|
||||||
validate_includes_no_cycle,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_config_profiles(
|
|
||||||
tool_type_id: str | None = None,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
query = select(ConfigProfile).where(ConfigProfile.user_id == user_id)
|
|
||||||
if tool_type_id:
|
|
||||||
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
|
|
||||||
if tool_type is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
|
||||||
result = await session.execute(query.order_by(ConfigProfile.name))
|
|
||||||
return {"profiles": [profile_to_dict(p) for p in result.scalars().all()]}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_config_profile(
|
|
||||||
data: ConfigProfileCreate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
await check_duplicate_name(session, user_id, data.name)
|
|
||||||
profile = ConfigProfile(user_id=user_id, name=data.name, description=data.description)
|
|
||||||
session.add(profile)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(profile)
|
|
||||||
return profile_to_dict(profile)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/defaults")
|
|
||||||
async def get_default_profiles_endpoint(
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
return await get_default_profiles(session, user_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/defaults")
|
|
||||||
async def set_default_profiles_endpoint(
|
|
||||||
data: DefaultProfilesUpdate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
return await set_default_profiles(session, user_id, data.default_profiles)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/defaults/{tool_type_id}")
|
|
||||||
async def get_default_profile_for_tool_type_endpoint(
|
|
||||||
tool_type_id: str,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
return await get_default_profile_for_tool_type(session, user_id, tool_type_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{profile_id}")
|
|
||||||
async def get_config_profile(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
profile = await session.get(
|
|
||||||
ConfigProfile,
|
|
||||||
profile_id,
|
|
||||||
options=[selectinload(ConfigProfile.includes), selectinload(ConfigProfile.mounts)],
|
|
||||||
)
|
|
||||||
if profile is None or profile.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config profile not found")
|
|
||||||
includes_data = []
|
|
||||||
for inc in profile.includes:
|
|
||||||
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
|
|
||||||
includes_data.append(include_to_dict(inc, included_profile.name if included_profile else None))
|
|
||||||
return {
|
|
||||||
**profile_to_dict(profile),
|
|
||||||
"includes": includes_data,
|
|
||||||
"mounts": [mount_to_dict(m) for m in profile.mounts],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{profile_id}")
|
|
||||||
async def update_config_profile(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
data: ConfigProfileUpdate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
profile = await get_owned_profile(profile_id, user_id, session)
|
|
||||||
if data.name is not None:
|
|
||||||
await check_duplicate_name(session, user_id, data.name, exclude_id=profile_id)
|
|
||||||
profile.name = data.name
|
|
||||||
if data.description is not None:
|
|
||||||
profile.description = data.description
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(profile)
|
|
||||||
return profile_to_dict(profile)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def delete_config_profile(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> None:
|
|
||||||
profile = await get_owned_profile(profile_id, user_id, session)
|
|
||||||
await session.delete(profile)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{profile_id}/includes")
|
|
||||||
async def list_profile_includes(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
await get_owned_profile(profile_id, user_id, session)
|
|
||||||
return await list_includes_for_profile(session, profile_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{profile_id}/includes", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def add_profile_include(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
data: ConfigIncludeCreate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
profile = await get_owned_profile(profile_id, user_id, session)
|
|
||||||
included_profile_id = uuid.UUID(data.included_profile_id)
|
|
||||||
if included_profile_id == profile_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="a profile cannot include itself")
|
|
||||||
included_profile = await session.get(ConfigProfile, included_profile_id)
|
|
||||||
if included_profile is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="included profile not found")
|
|
||||||
if included_profile.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="included profile does not belong to user")
|
|
||||||
await check_duplicate_include(session, profile_id, included_profile_id)
|
|
||||||
await validate_includes_no_cycle(session, profile_id, included_profile_id)
|
|
||||||
include = ConfigInclude(
|
|
||||||
profile_id=profile_id,
|
|
||||||
included_profile_id=included_profile_id,
|
|
||||||
order_index=data.order_index,
|
|
||||||
)
|
|
||||||
session.add(include)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(include)
|
|
||||||
return include_to_dict(include, included_profile.name)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{profile_id}/includes/{include_id}")
|
|
||||||
async def update_profile_include(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
include_id: uuid.UUID,
|
|
||||||
data: ConfigIncludeUpdate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
await get_owned_profile(profile_id, user_id, session)
|
|
||||||
include = await session.get(ConfigInclude, include_id)
|
|
||||||
if include is None or include.profile_id != profile_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found")
|
|
||||||
include.order_index = data.order_index
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(include)
|
|
||||||
included_profile = await session.get(ConfigProfile, include.included_profile_id)
|
|
||||||
return include_to_dict(include, included_profile.name if included_profile else None)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{profile_id}/includes/{include_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def remove_profile_include(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
include_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> None:
|
|
||||||
await get_owned_profile(profile_id, user_id, session)
|
|
||||||
include = await session.get(ConfigInclude, include_id)
|
|
||||||
if include is None or include.profile_id != profile_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found")
|
|
||||||
await session.delete(include)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{profile_id}/mounts")
|
|
||||||
async def list_profile_mounts(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
await get_owned_profile(profile_id, user_id, session)
|
|
||||||
return await list_mounts_for_profile(session, profile_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{profile_id}/mounts", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def add_profile_mount(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
data: ConfigMountCreate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
profile = await get_owned_profile(profile_id, user_id, session)
|
|
||||||
await check_duplicate_mount_path(session, profile_id, data.target_path)
|
|
||||||
mount = ConfigMount(
|
|
||||||
profile_id=profile_id,
|
|
||||||
target_path=data.target_path,
|
|
||||||
mode=data.mode,
|
|
||||||
files=data.files,
|
|
||||||
order_index=data.order_index,
|
|
||||||
)
|
|
||||||
session.add(mount)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(mount)
|
|
||||||
return mount_to_dict(mount)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{profile_id}/mounts/{mount_id}")
|
|
||||||
async def update_profile_mount(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
mount_id: uuid.UUID,
|
|
||||||
data: ConfigMountUpdate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
await get_owned_profile(profile_id, user_id, session)
|
|
||||||
mount = await session.get(ConfigMount, mount_id)
|
|
||||||
if mount is None or mount.profile_id != profile_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found")
|
|
||||||
if data.target_path is not None:
|
|
||||||
await check_duplicate_mount_path(session, profile_id, data.target_path, exclude_id=mount_id)
|
|
||||||
mount.target_path = data.target_path
|
|
||||||
if data.files is not None:
|
|
||||||
mount.files = data.files
|
|
||||||
if data.order_index is not None:
|
|
||||||
mount.order_index = data.order_index
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(mount)
|
|
||||||
return mount_to_dict(mount)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{profile_id}/mounts/{mount_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def remove_profile_mount(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
mount_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> None:
|
|
||||||
await get_owned_profile(profile_id, user_id, session)
|
|
||||||
mount = await session.get(ConfigMount, mount_id)
|
|
||||||
if mount is None or mount.profile_id != profile_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found")
|
|
||||||
await session.delete(mount)
|
|
||||||
await session.commit()
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
"""Git repository API endpoints."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user, get_db_session, get_owned_project
|
|
||||||
from src.models.project import Project
|
|
||||||
from src.models.user import User
|
|
||||||
from src.schemas.git_repository import (
|
|
||||||
BranchCreateRequest,
|
|
||||||
CheckoutRequest,
|
|
||||||
CommitRequest,
|
|
||||||
CommitResponse,
|
|
||||||
FetchResponse,
|
|
||||||
FileContentResponse,
|
|
||||||
FileListResponse,
|
|
||||||
FileUpdateRequest,
|
|
||||||
FileUpdateResponse,
|
|
||||||
GitRepositoryCreate,
|
|
||||||
GitRepositoryResponse,
|
|
||||||
MergeRequest,
|
|
||||||
MergeResponse,
|
|
||||||
PullResponse,
|
|
||||||
PushResponse,
|
|
||||||
StatusResponse,
|
|
||||||
URLParseRequest,
|
|
||||||
URLParseResponse,
|
|
||||||
)
|
|
||||||
from src.services.git import control as git_control
|
|
||||||
from src.services.git import files as git_files
|
|
||||||
from src.services.git.repository import create_repository, delete_repository, list_repositories
|
|
||||||
from src.utils.git_url_parser import parse_git_url
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories", response_model=list[GitRepositoryResponse])
|
|
||||||
async def list_repositories_endpoint(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
):
|
|
||||||
return await list_repositories(session, project_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_repository_endpoint(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
data: GitRepositoryCreate,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
):
|
|
||||||
return await create_repository(session, project_id, data, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def delete_repository_endpoint(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
):
|
|
||||||
await delete_repository(session, repo_id, project_id)
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/repositories/parse-url", response_model=URLParseResponse)
|
|
||||||
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
|
||||||
return URLParseResponse(**parse_git_url(data.url))
|
|
||||||
|
|
||||||
|
|
||||||
# History
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/history")
|
|
||||||
async def get_repository_history(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
view: str = "graph",
|
|
||||||
branch: str | None = None,
|
|
||||||
limit: int = 100,
|
|
||||||
offset: int = 0,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
from src.utils.git_history import get_commit_history
|
|
||||||
from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
return get_commit_history(repo.path, branch=branch, limit=limit, offset=offset)
|
|
||||||
except RuntimeError as e:
|
|
||||||
logger.warning("Git history failed for %s: %s", repo.path, str(e))
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Git history unavailable: {str(e)}",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/commits/{commit_hash}")
|
|
||||||
async def get_repository_commit(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
commit_hash: str,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
from src.utils.git_history import get_commit_detail
|
|
||||||
from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
return get_commit_detail(repo.path, commit_hash)
|
|
||||||
except RuntimeError as e:
|
|
||||||
logger.warning("Git commit detail failed for %s %s: %s", repo.path, commit_hash, str(e))
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Commit detail unavailable: {str(e)}",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
# File browsing
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/files", response_model=FileListResponse)
|
|
||||||
async def list_repository_files(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch: str = "main",
|
|
||||||
path: str = "",
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> FileListResponse:
|
|
||||||
return await git_files.list_files(session, project_id, repo_id, branch, path)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/files/content", response_model=FileContentResponse)
|
|
||||||
async def get_repository_file_content(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch: str,
|
|
||||||
path: str,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> FileContentResponse:
|
|
||||||
return await git_files.get_file(session, project_id, repo_id, branch, path)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/files/content", response_model=FileUpdateResponse)
|
|
||||||
async def update_repository_file(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: FileUpdateRequest,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> FileUpdateResponse:
|
|
||||||
return await git_files.update_file(session, project_id, repo_id, data, user)
|
|
||||||
|
|
||||||
|
|
||||||
# Branches
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/branches")
|
|
||||||
async def get_repository_branches(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
return await git_files.list_branches_with_validation(session, project_id, repo_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/branches")
|
|
||||||
async def create_repository_branch(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: BranchCreateRequest,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
return await git_control.create_branch_with_validation(session, project_id, repo_id, data)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{project_id}/repositories/{repo_id}/branches/{branch_name}")
|
|
||||||
async def delete_repository_branch(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch_name: str,
|
|
||||||
force: bool = False,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
return await git_control.delete_branch_with_validation(session, project_id, repo_id, branch_name, force)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/checkout")
|
|
||||||
async def checkout_repository_branch(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: CheckoutRequest,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
return await git_control.checkout_branch_with_validation(session, project_id, repo_id, data)
|
|
||||||
|
|
||||||
|
|
||||||
# Git control
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/status", response_model=StatusResponse)
|
|
||||||
async def get_repository_status(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> StatusResponse:
|
|
||||||
return await git_control.get_status_with_validation(session, project_id, repo_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/commit", response_model=CommitResponse)
|
|
||||||
async def commit_repository_changes(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: CommitRequest,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> CommitResponse:
|
|
||||||
result = await git_control.commit_changes_with_validation(session, project_id, repo_id, data, user)
|
|
||||||
return CommitResponse(commit_hash=result["commit_hash"], message=result["message"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/fetch", response_model=FetchResponse)
|
|
||||||
async def fetch_repository(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> FetchResponse:
|
|
||||||
return await git_control.fetch_with_validation(session, project_id, repo_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/pull", response_model=PullResponse)
|
|
||||||
async def pull_repository(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch: str | None = None,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> PullResponse:
|
|
||||||
return await git_control.pull_with_validation(session, project_id, repo_id, branch)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/push", response_model=PushResponse)
|
|
||||||
async def push_repository(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch: str | None = None,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> PushResponse:
|
|
||||||
return await git_control.push_with_validation(session, project_id, repo_id, branch)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/merge", response_model=MergeResponse)
|
|
||||||
async def merge_repository_branches(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: MergeRequest,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> MergeResponse:
|
|
||||||
return await git_control.merge_with_validation(session, project_id, repo_id, data)
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""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"]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,25 +3,29 @@ import shutil
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user, get_db_session, get_owned_project
|
from src.auth.dependencies import (
|
||||||
from src.models.git_repository import GitRepository
|
_get_owned_project,
|
||||||
|
_get_user,
|
||||||
|
get_current_user_id,
|
||||||
|
get_db_session,
|
||||||
|
)
|
||||||
|
from src.models import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models import SSHKey
|
||||||
from src.models.user import User
|
from src.models import ToolInstance
|
||||||
from src.schemas.project import (
|
from src.schemas.project import (
|
||||||
ProjectCreate,
|
ProjectCreate,
|
||||||
ProjectUpdate,
|
|
||||||
ProjectResponse,
|
ProjectResponse,
|
||||||
|
ProjectUpdate,
|
||||||
SetDefaultSSHKeyRequest,
|
SetDefaultSSHKeyRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=ProjectResponse,
|
response_model=ProjectResponse,
|
||||||
@@ -31,7 +35,7 @@ router = APIRouter(prefix="/projects", tags=["projects"])
|
|||||||
)
|
)
|
||||||
async def create_project(
|
async def create_project(
|
||||||
data: ProjectCreate,
|
data: ProjectCreate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Create a new project.
|
"""Create a new project.
|
||||||
@@ -44,6 +48,7 @@ async def create_project(
|
|||||||
Returns:
|
Returns:
|
||||||
The newly created project.
|
The newly created project.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
project = Project(
|
project = Project(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
@@ -58,25 +63,77 @@ async def create_project(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"",
|
"",
|
||||||
response_model=list[ProjectResponse],
|
|
||||||
summary="List all projects",
|
summary="List all projects",
|
||||||
description="Retrieve all projects owned by the authenticated user.",
|
description="Retrieve all projects owned by the authenticated user with repositories and workspaces.",
|
||||||
)
|
)
|
||||||
async def list_projects(
|
async def list_projects(
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[Project]:
|
) -> list[dict]:
|
||||||
"""List all projects for the authenticated user.
|
"""List all projects for the authenticated user.
|
||||||
|
|
||||||
Args:
|
Returns projects with nested repositories and workspaces for inline display.
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
session: Database session.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of projects owned by the user.
|
|
||||||
"""
|
"""
|
||||||
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
user = await _get_user(session, user_id)
|
||||||
return list(result.scalars().all())
|
result = await session.execute(
|
||||||
|
select(Project)
|
||||||
|
.where(Project.owner_id == user.id)
|
||||||
|
.order_by(Project.created_at.desc())
|
||||||
|
)
|
||||||
|
projects = result.scalars().all()
|
||||||
|
|
||||||
|
from src.models import Workspace
|
||||||
|
|
||||||
|
enriched = []
|
||||||
|
for project in projects:
|
||||||
|
repos_result = await session.execute(
|
||||||
|
select(GitRepository).where(GitRepository.project_id == project.id)
|
||||||
|
)
|
||||||
|
repositories = []
|
||||||
|
for repo in repos_result.scalars().all():
|
||||||
|
ws_result = await session.execute(
|
||||||
|
select(Workspace).where(Workspace.repo_id == repo.id)
|
||||||
|
)
|
||||||
|
workspaces = []
|
||||||
|
for ws in ws_result.scalars().all():
|
||||||
|
# Count instances
|
||||||
|
inst_result = await session.execute(
|
||||||
|
select(func.count()).where(ToolInstance.workspace_id == ws.id)
|
||||||
|
)
|
||||||
|
instance_count = inst_result.scalar() or 0
|
||||||
|
workspaces.append(
|
||||||
|
{
|
||||||
|
"id": str(ws.id),
|
||||||
|
"name": ws.name,
|
||||||
|
"branch": ws.branch,
|
||||||
|
"status": ws.status,
|
||||||
|
"instance_count": instance_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
repositories.append(
|
||||||
|
{
|
||||||
|
"id": str(repo.id),
|
||||||
|
"name": repo.name,
|
||||||
|
"remote_url": repo.remote_url,
|
||||||
|
"workspaces": workspaces,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
enriched.append(
|
||||||
|
{
|
||||||
|
"id": str(project.id),
|
||||||
|
"name": project.name,
|
||||||
|
"description": project.description,
|
||||||
|
"owner_id": str(project.owner_id),
|
||||||
|
"repositories": repositories,
|
||||||
|
"created_at": project.created_at.isoformat()
|
||||||
|
if project.created_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -87,8 +144,7 @@ async def list_projects(
|
|||||||
)
|
)
|
||||||
async def get_project(
|
async def get_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Get a specific project by ID.
|
"""Get a specific project by ID.
|
||||||
@@ -101,8 +157,8 @@ async def get_project(
|
|||||||
Returns:
|
Returns:
|
||||||
The requested project.
|
The requested project.
|
||||||
"""
|
"""
|
||||||
return project
|
await _get_user(session, user_id)
|
||||||
|
return await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
@@ -114,8 +170,7 @@ async def get_project(
|
|||||||
async def update_project(
|
async def update_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
data: ProjectUpdate,
|
data: ProjectUpdate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Update a project.
|
"""Update a project.
|
||||||
@@ -129,6 +184,8 @@ async def update_project(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated project.
|
The updated project.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
project.name = data.name
|
project.name = data.name
|
||||||
@@ -148,8 +205,7 @@ async def update_project(
|
|||||||
)
|
)
|
||||||
async def delete_project(
|
async def delete_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Delete a project and all its repositories.
|
"""Delete a project and all its repositories.
|
||||||
@@ -162,9 +218,13 @@ async def delete_project(
|
|||||||
Returns:
|
Returns:
|
||||||
Empty response with 204 status code.
|
Empty response with 204 status code.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
# Delete repositories from disk and database
|
# Delete repositories from disk and database
|
||||||
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
|
result = await session.execute(
|
||||||
|
select(GitRepository).where(GitRepository.project_id == project_id)
|
||||||
|
)
|
||||||
repositories = result.scalars().all()
|
repositories = result.scalars().all()
|
||||||
for repo in repositories:
|
for repo in repositories:
|
||||||
if os.path.exists(repo.path):
|
if os.path.exists(repo.path):
|
||||||
@@ -185,8 +245,7 @@ async def delete_project(
|
|||||||
async def set_default_ssh_key(
|
async def set_default_ssh_key(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
data: SetDefaultSSHKeyRequest,
|
data: SetDefaultSSHKeyRequest,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Set the default SSH key for a project.
|
"""Set the default SSH key for a project.
|
||||||
@@ -200,6 +259,8 @@ async def set_default_ssh_key(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated project.
|
The updated project.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
ssh_key = await session.get(SSHKey, data.ssh_key_id)
|
ssh_key = await session.get(SSHKey, data.ssh_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:
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
@@ -5,9 +5,9 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.git_repository import GitRepository
|
from src.models import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models import SSHKey
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
@@ -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"])
|
||||||
|
|
||||||
@@ -4,12 +4,11 @@ import time
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from src.config import Settings
|
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
from src.schemas.health import (
|
from src.schemas.system import (
|
||||||
DatabaseHealth,
|
DatabaseHealth,
|
||||||
DatabaseHealthResponse,
|
DatabaseHealthResponse,
|
||||||
DiskHealth,
|
DiskHealth,
|
||||||
@@ -8,8 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.tool_instance import ToolInstance
|
from src.models import ToolInstance
|
||||||
from src.models.tool_type import ToolType
|
from src.models import ToolType
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -9,8 +9,8 @@ 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.user_config 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"])
|
||||||
|
|
||||||
@@ -0,0 +1,749 @@
|
|||||||
|
"""WebSocket terminal endpoint for tool instances."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models import TerminalSessionModel
|
||||||
|
from src.models import ToolInstance
|
||||||
|
from src.models import ToolType
|
||||||
|
from src.services.terminal.terminal_manager import MaxSessionsExceededError, terminal_manager
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRef:
|
||||||
|
"""Mutable reference to a terminal session, allowing updates during reset."""
|
||||||
|
|
||||||
|
def __init__(self, session, slot_session_id: str | None = None):
|
||||||
|
self.session = session
|
||||||
|
self.slot_session_id = slot_session_id or session.session_id
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket(
|
||||||
|
"/ws/tool-instances/{instance_id}/terminal",
|
||||||
|
)
|
||||||
|
async def terminal_websocket_default(
|
||||||
|
websocket: WebSocket,
|
||||||
|
instance_id: str,
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> None:
|
||||||
|
"""WebSocket endpoint for terminal access (default session alias).
|
||||||
|
|
||||||
|
Backward-compatible route that maps to the default session.
|
||||||
|
"""
|
||||||
|
await _handle_terminal_websocket(websocket, instance_id, None, db_session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket(
|
||||||
|
"/ws/tool-instances/{instance_id}/terminal/{session_id}",
|
||||||
|
)
|
||||||
|
async def terminal_websocket_specific(
|
||||||
|
websocket: WebSocket,
|
||||||
|
instance_id: str,
|
||||||
|
session_id: str,
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> None:
|
||||||
|
"""WebSocket endpoint for a specific terminal session."""
|
||||||
|
await _handle_terminal_websocket(websocket, instance_id, session_id, db_session)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_terminal_websocket(
|
||||||
|
websocket: WebSocket,
|
||||||
|
instance_id: str,
|
||||||
|
target_session_id: str | None,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""Shared WebSocket handler for terminal sessions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
websocket: The WebSocket connection.
|
||||||
|
instance_id: UUID string of the tool instance.
|
||||||
|
target_session_id: Specific session ID (slot key). None means default session.
|
||||||
|
db_session: Database session.
|
||||||
|
"""
|
||||||
|
logger.debug(
|
||||||
|
"Terminal WebSocket connection attempt for instance %s (session=%s)",
|
||||||
|
instance_id,
|
||||||
|
target_session_id or "default",
|
||||||
|
)
|
||||||
|
await websocket.accept()
|
||||||
|
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse instance_id
|
||||||
|
instance_uuid = uuid.UUID(instance_id)
|
||||||
|
except ValueError:
|
||||||
|
logger.error("Invalid instance ID: %s", instance_id)
|
||||||
|
await websocket.close(code=4001, reason="Invalid instance ID")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Authenticate user from session cookie
|
||||||
|
user_id = await _get_user_from_websocket(websocket, db_session)
|
||||||
|
if user_id is None:
|
||||||
|
logger.warning(
|
||||||
|
"Unauthorized terminal access attempt for instance %s", instance_id
|
||||||
|
)
|
||||||
|
await websocket.close(code=4003, reason="Unauthorized")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get instance and verify ownership
|
||||||
|
instance = await db_session.get(ToolInstance, instance_uuid)
|
||||||
|
if instance is None:
|
||||||
|
logger.warning("Instance %s not found", instance_id)
|
||||||
|
await websocket.close(code=4004, reason="Instance not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
if instance.owner_id != user_id:
|
||||||
|
logger.warning(
|
||||||
|
"Forbidden terminal access for instance %s by user %s",
|
||||||
|
instance_id,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
await websocket.close(code=4003, reason="Forbidden")
|
||||||
|
return
|
||||||
|
|
||||||
|
if instance.status != "running" or not instance.container_id:
|
||||||
|
logger.warning(
|
||||||
|
"Instance %s not running (status=%s, container_id=%s)",
|
||||||
|
instance_id,
|
||||||
|
instance.status,
|
||||||
|
instance.container_id,
|
||||||
|
)
|
||||||
|
await websocket.close(code=4004, reason="Instance not running")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug("Terminal auth passed for instance %s, user %s", instance_id, user_id)
|
||||||
|
|
||||||
|
# Verify the container actually exists (may have been removed/recreated)
|
||||||
|
from src.services.docker import get_container_status
|
||||||
|
|
||||||
|
container_status = get_container_status(instance.container_id)
|
||||||
|
if container_status["status"] == "not_found":
|
||||||
|
logger.error(
|
||||||
|
"Container %s for instance %s not found (may have been removed)",
|
||||||
|
instance.container_id,
|
||||||
|
instance_id,
|
||||||
|
)
|
||||||
|
await websocket.close(
|
||||||
|
code=4004, reason="Container not found — restart the tool instance"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fetch tool type to get startup_command
|
||||||
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
if startup_command:
|
||||||
|
logger.debug(
|
||||||
|
"Using startup command for instance %s: %s",
|
||||||
|
instance_id,
|
||||||
|
startup_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
session = None
|
||||||
|
|
||||||
|
# Get or create terminal session
|
||||||
|
try:
|
||||||
|
if target_session_id is None:
|
||||||
|
# Default session alias
|
||||||
|
session = await terminal_manager.get_or_create_session(
|
||||||
|
instance_uuid,
|
||||||
|
instance.container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
)
|
||||||
|
slot_session_id = "default"
|
||||||
|
else:
|
||||||
|
# Specific session
|
||||||
|
session = terminal_manager.get_session(
|
||||||
|
instance_id,
|
||||||
|
target_session_id,
|
||||||
|
)
|
||||||
|
if session is None:
|
||||||
|
# Session not in memory — may have been lost on server restart.
|
||||||
|
# Try to restore from the DB row.
|
||||||
|
db_row = await db_session.get(
|
||||||
|
TerminalSessionModel, uuid.UUID(target_session_id)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
db_row is not None
|
||||||
|
and db_row.instance_id == instance_uuid
|
||||||
|
and db_row.status != "closed"
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"Restoring terminal session %s for instance %s from DB",
|
||||||
|
target_session_id,
|
||||||
|
instance_id,
|
||||||
|
)
|
||||||
|
session = await terminal_manager.create_session(
|
||||||
|
instance_uuid,
|
||||||
|
instance.container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
name=db_row.name,
|
||||||
|
session_id=target_session_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Session %s not found for instance %s",
|
||||||
|
target_session_id,
|
||||||
|
instance_id,
|
||||||
|
)
|
||||||
|
await websocket.close(code=4004, reason="Session not found")
|
||||||
|
return
|
||||||
|
# Determine slot key for reset scoping
|
||||||
|
key = terminal_manager._find_key_by_internal_id(
|
||||||
|
instance_id, session.session_id
|
||||||
|
)
|
||||||
|
slot_session_id = key[1] if key else target_session_id
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Terminal session ready for instance %s (session_id=%s, slot=%s)",
|
||||||
|
instance_id,
|
||||||
|
session.session_id,
|
||||||
|
slot_session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach WebSocket to session
|
||||||
|
await terminal_manager.attach_websocket(session, websocket)
|
||||||
|
logger.debug("WebSocket attached to session for instance %s", instance_id)
|
||||||
|
|
||||||
|
# Send connected status
|
||||||
|
await websocket.send_json({"type": "status", "status": "connected"})
|
||||||
|
logger.debug("Sent connected status for instance %s", instance_id)
|
||||||
|
|
||||||
|
# Use mutable session reference so loops can survive reset
|
||||||
|
session_ref = SessionRef(session, slot_session_id)
|
||||||
|
|
||||||
|
# Start write loop and heartbeat (read is now event-driven in TerminalSession)
|
||||||
|
write_task = asyncio.create_task(
|
||||||
|
_write_loop(session_ref, websocket, instance_id)
|
||||||
|
)
|
||||||
|
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
||||||
|
logger.debug("Started terminal loops for instance %s", instance_id)
|
||||||
|
|
||||||
|
# Wait for either task to complete (indicating disconnect or error)
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
[write_task, heartbeat_task],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Terminal loop completed for instance %s, done=%s",
|
||||||
|
instance_id,
|
||||||
|
len(done),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cancel remaining tasks
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.debug("WebSocket disconnected for instance %s", instance_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Terminal session error for instance %s: %s",
|
||||||
|
instance_id,
|
||||||
|
str(exc),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
with suppress(Exception):
|
||||||
|
await websocket.close(code=4000, reason=f"Error: {exc}")
|
||||||
|
finally:
|
||||||
|
# Detach WebSocket, don't kill session
|
||||||
|
with suppress(Exception):
|
||||||
|
if session is not None:
|
||||||
|
await terminal_manager.detach_websocket(session, websocket)
|
||||||
|
logger.debug(
|
||||||
|
"WebSocket detached from session for instance %s", instance_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
||||||
|
"""Read input from WebSocket and send to container."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
session = session_ref.session
|
||||||
|
if not session.is_alive() or session._closed:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
continue
|
||||||
|
message = await websocket.receive()
|
||||||
|
if message["type"] == "websocket.receive":
|
||||||
|
if "bytes" in message:
|
||||||
|
await session.write_input(message["bytes"])
|
||||||
|
elif "text" in message:
|
||||||
|
text = message["text"]
|
||||||
|
if text.startswith("{"):
|
||||||
|
# Control message (JSON)
|
||||||
|
try:
|
||||||
|
ctrl = json.loads(text)
|
||||||
|
msg_type = ctrl.get("type")
|
||||||
|
|
||||||
|
if msg_type == "resize":
|
||||||
|
cols = ctrl.get("cols", 80)
|
||||||
|
rows = ctrl.get("rows", 24)
|
||||||
|
logger.debug(
|
||||||
|
"Received resize message for instance %s: %sx%s",
|
||||||
|
instance_id,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
await session.resize(cols, rows)
|
||||||
|
elif msg_type == "ack":
|
||||||
|
char_count = ctrl.get("chars", 0)
|
||||||
|
if char_count > 0:
|
||||||
|
session.acknowledge_data(char_count)
|
||||||
|
elif msg_type == "reset":
|
||||||
|
# Reset terminal session (scoped to current slot)
|
||||||
|
logger.debug(
|
||||||
|
"Resetting terminal session for instance %s (slot=%s)",
|
||||||
|
session.instance_id,
|
||||||
|
session_ref.slot_session_id,
|
||||||
|
)
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "status", "status": "resetting"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reset the session scoped to its slot
|
||||||
|
new_session = await terminal_manager.reset_session(
|
||||||
|
session.instance_id,
|
||||||
|
session.container_id,
|
||||||
|
startup_command=session.startup_command,
|
||||||
|
session_id=session_ref.slot_session_id,
|
||||||
|
name=session.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the mutable session reference
|
||||||
|
session_ref.session = new_session
|
||||||
|
|
||||||
|
# Attach to new session
|
||||||
|
await terminal_manager.attach_websocket(
|
||||||
|
new_session, websocket
|
||||||
|
)
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "status", "status": "connected"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Continue the loop with the new session
|
||||||
|
continue
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Not a valid JSON control message, treat as regular input
|
||||||
|
await session.write_input(text.encode("utf-8"))
|
||||||
|
else:
|
||||||
|
await session.write_input(text.encode("utf-8"))
|
||||||
|
elif message["type"] == "websocket.disconnect":
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _heartbeat_loop(websocket: WebSocket) -> None:
|
||||||
|
"""Send periodic ping messages to detect disconnections."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(30) # Ping every 30 seconds
|
||||||
|
try:
|
||||||
|
await websocket.send_json({"type": "ping"})
|
||||||
|
except Exception:
|
||||||
|
# WebSocket is closed or broken
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_terminal_instance(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> ToolInstance:
|
||||||
|
"""Fetch instance and validate auth, ownership, and running status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated ToolInstance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If instance not found, not owned, or not running.
|
||||||
|
"""
|
||||||
|
instance = await db_session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if instance.owner_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to access this instance",
|
||||||
|
)
|
||||||
|
|
||||||
|
if instance.status != "running" or not instance.container_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Instance is not running"
|
||||||
|
)
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/instances/{instance_id}/terminal/sessions",
|
||||||
|
summary="List terminal sessions",
|
||||||
|
description="List terminal sessions for a tool instance with live WebSocket state.",
|
||||||
|
)
|
||||||
|
async def list_terminal_sessions(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""List terminal sessions for an instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with sessions list.
|
||||||
|
"""
|
||||||
|
await _get_terminal_instance(instance_id, user_id, db_session)
|
||||||
|
|
||||||
|
# Query active DB rows for this instance
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(TerminalSessionModel)
|
||||||
|
.where(TerminalSessionModel.instance_id == instance_id)
|
||||||
|
.where(TerminalSessionModel.status != "closed")
|
||||||
|
.order_by(TerminalSessionModel.created_at.asc())
|
||||||
|
)
|
||||||
|
db_rows = result.scalars().all()
|
||||||
|
|
||||||
|
# Build response with live has_websockets flag.
|
||||||
|
# Include DB rows even without in-memory counterparts (e.g. after
|
||||||
|
# server restart) so the frontend can display tabs and reconnect.
|
||||||
|
sessions = []
|
||||||
|
for row in db_rows:
|
||||||
|
live_session = terminal_manager.get_session(str(instance_id), str(row.id))
|
||||||
|
sessions.append(
|
||||||
|
{
|
||||||
|
"id": str(row.id),
|
||||||
|
"name": row.name,
|
||||||
|
"status": row.status,
|
||||||
|
"has_websockets": live_session.has_websockets()
|
||||||
|
if live_session
|
||||||
|
else False,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"last_activity_at": row.last_activity_at.isoformat()
|
||||||
|
if row.last_activity_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"sessions": sessions}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/terminal/sessions",
|
||||||
|
summary="Create terminal session",
|
||||||
|
description="Create a new terminal session for a running tool instance.",
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_terminal_session(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Create a new terminal session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
data: Request body with optional name.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with new session details.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 409 if max sessions reached.
|
||||||
|
"""
|
||||||
|
instance = await _get_terminal_instance(instance_id, user_id, db_session)
|
||||||
|
assert instance.container_id is not None
|
||||||
|
|
||||||
|
# Fetch tool type to get startup_command
|
||||||
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
|
||||||
|
name = data.get("name")
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = await terminal_manager.create_session(
|
||||||
|
instance_id,
|
||||||
|
instance.container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
except MaxSessionsExceededError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Maximum of 5 terminal sessions reached for this instance",
|
||||||
|
) from None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": session.session_id,
|
||||||
|
"name": session.name,
|
||||||
|
"status": session.status,
|
||||||
|
"created_at": session.last_activity,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/instances/{instance_id}/terminal/sessions/{session_id}",
|
||||||
|
summary="Close terminal session",
|
||||||
|
description="Close a specific terminal session.",
|
||||||
|
)
|
||||||
|
async def close_terminal_session(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
session_id: str,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Close a terminal session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
session_id: ID of the session to close.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with closure status.
|
||||||
|
"""
|
||||||
|
await _get_terminal_instance(instance_id, user_id, db_session)
|
||||||
|
|
||||||
|
# Find the session by internal ID to determine its slot key
|
||||||
|
key = terminal_manager._find_key_by_internal_id(str(instance_id), session_id)
|
||||||
|
if (
|
||||||
|
key is None
|
||||||
|
and terminal_manager.get_session(str(instance_id), session_id) is not None
|
||||||
|
):
|
||||||
|
key = (str(instance_id), session_id)
|
||||||
|
|
||||||
|
if key is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
await terminal_manager.close_session(key[0], key[1])
|
||||||
|
|
||||||
|
return {"status": "closed", "session_id": session_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/terminal/sessions/{session_id}/reset",
|
||||||
|
summary="Reset terminal session",
|
||||||
|
description="Reset a specific terminal session, killing the current shell and starting fresh.",
|
||||||
|
)
|
||||||
|
async def reset_specific_terminal_session(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
session_id: str,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Reset a specific terminal session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
session_id: ID of the session to reset.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with reset session details.
|
||||||
|
"""
|
||||||
|
instance = await _get_terminal_instance(instance_id, user_id, db_session)
|
||||||
|
assert instance.container_id is not None
|
||||||
|
|
||||||
|
# Determine slot key for reset
|
||||||
|
key = terminal_manager._find_key_by_internal_id(str(instance_id), session_id)
|
||||||
|
if (
|
||||||
|
key is None
|
||||||
|
and terminal_manager.get_session(str(instance_id), session_id) is not None
|
||||||
|
):
|
||||||
|
key = (str(instance_id), session_id)
|
||||||
|
|
||||||
|
if key is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch tool type to get startup_command
|
||||||
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
|
||||||
|
# Preserve name if possible
|
||||||
|
live_session = terminal_manager.get_session(str(instance_id), session_id)
|
||||||
|
name = live_session.name if live_session else None
|
||||||
|
|
||||||
|
new_session = await terminal_manager.reset_session(
|
||||||
|
instance_id,
|
||||||
|
instance.container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
session_id=key[1],
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": new_session.session_id,
|
||||||
|
"name": new_session.name,
|
||||||
|
"status": new_session.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/terminal/sessions/{session_id}/rename",
|
||||||
|
summary="Rename terminal session",
|
||||||
|
description="Rename a specific terminal session.",
|
||||||
|
)
|
||||||
|
async def rename_terminal_session(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
session_id: str,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Rename a terminal session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
session_id: ID of the session to rename.
|
||||||
|
data: Request body with new name.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with updated session details.
|
||||||
|
"""
|
||||||
|
await _get_terminal_instance(instance_id, user_id, db_session)
|
||||||
|
|
||||||
|
new_name = data.get("name")
|
||||||
|
if not new_name or not isinstance(new_name, str):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Name is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update in-memory session name if live
|
||||||
|
live_session = terminal_manager.get_session(str(instance_id), session_id)
|
||||||
|
if live_session:
|
||||||
|
live_session.name = new_name
|
||||||
|
|
||||||
|
# Update DB row
|
||||||
|
db_row = await db_session.get(TerminalSessionModel, uuid.UUID(session_id))
|
||||||
|
if db_row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
db_row.name = new_name
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
return {"id": str(db_row.id), "name": new_name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/terminal/reset",
|
||||||
|
summary="Reset terminal session (legacy alias)",
|
||||||
|
description="Reset the default terminal session for a tool instance. Preserved for backward compatibility.",
|
||||||
|
)
|
||||||
|
async def reset_terminal_session(
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Reset the default terminal session for an instance (legacy alias).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with status message.
|
||||||
|
"""
|
||||||
|
instance = await _get_terminal_instance(instance_id, user_id, db_session)
|
||||||
|
assert instance.container_id is not None
|
||||||
|
|
||||||
|
# Fetch tool type to get startup_command
|
||||||
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Reset the default session
|
||||||
|
new_session = await terminal_manager.reset_session(
|
||||||
|
instance_id,
|
||||||
|
instance.container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Terminal session reset for instance %s (new session_id=%s)",
|
||||||
|
instance_id,
|
||||||
|
new_session.session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Terminal session reset successfully",
|
||||||
|
"instance_id": str(instance_id),
|
||||||
|
"session_id": new_session.session_id,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to reset terminal session for instance %s: %s",
|
||||||
|
instance_id,
|
||||||
|
str(exc),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to reset terminal session: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_user_from_websocket(
|
||||||
|
websocket: WebSocket,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> uuid.UUID | None:
|
||||||
|
"""Extract and validate user ID from session cookie in WebSocket.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
websocket: The WebSocket connection.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The user's UUID if authenticated, None otherwise.
|
||||||
|
"""
|
||||||
|
from src.auth.session import decode_session_cookie
|
||||||
|
from src.config import Settings
|
||||||
|
|
||||||
|
session_cookie = websocket.cookies.get("session")
|
||||||
|
if not session_cookie:
|
||||||
|
return None
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
try:
|
||||||
|
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||||
|
return uuid.UUID(str(payload["user_id"]))
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
return None
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
"""WebSocket terminal endpoint for tool instances."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, WebSocket
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.auth.dependencies import get_db_session
|
|
||||||
from src.models.tool_instance import ToolInstance
|
|
||||||
from src.services.terminal_manager import terminal_manager
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket(
|
|
||||||
"/ws/tool-instances/{instance_id}/terminal",
|
|
||||||
)
|
|
||||||
async def terminal_websocket(
|
|
||||||
websocket: WebSocket,
|
|
||||||
instance_id: str,
|
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> None:
|
|
||||||
"""WebSocket endpoint for terminal access to a tool instance.
|
|
||||||
|
|
||||||
Provides an interactive terminal session inside a running tool instance container.
|
|
||||||
Supports:
|
|
||||||
- Auto-reconnection (client reconnects, server spawns new session)
|
|
||||||
- Heartbeat ping/pong
|
|
||||||
- Binary and text input frames
|
|
||||||
- Graceful session end notifications
|
|
||||||
|
|
||||||
Args:
|
|
||||||
websocket: The WebSocket connection.
|
|
||||||
instance_id: UUID string of the tool instance.
|
|
||||||
db_session: Database session.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None. Communicates via WebSocket messages.
|
|
||||||
|
|
||||||
"""
|
|
||||||
logger.info("Terminal WebSocket connection attempt for instance %s", instance_id)
|
|
||||||
await websocket.accept()
|
|
||||||
|
|
||||||
try:
|
|
||||||
instance_uuid = uuid.UUID(instance_id)
|
|
||||||
except ValueError:
|
|
||||||
logger.error("Invalid instance ID: %s", instance_id)
|
|
||||||
await websocket.close(code=4001, reason="Invalid instance ID")
|
|
||||||
return
|
|
||||||
|
|
||||||
user_id = await _get_user_from_websocket(websocket, db_session)
|
|
||||||
if user_id is None:
|
|
||||||
logger.warning(
|
|
||||||
"Unauthorized terminal access attempt for instance %s",
|
|
||||||
instance_id,
|
|
||||||
)
|
|
||||||
await websocket.close(code=4003, reason="Unauthorized")
|
|
||||||
return
|
|
||||||
|
|
||||||
instance = await db_session.get(ToolInstance, instance_uuid)
|
|
||||||
if instance is None:
|
|
||||||
logger.warning("Instance %s not found", instance_id)
|
|
||||||
await websocket.close(code=4004, reason="Instance not found")
|
|
||||||
return
|
|
||||||
|
|
||||||
if instance.owner_id != user_id:
|
|
||||||
logger.warning(
|
|
||||||
"Forbidden terminal access for instance %s by user %s",
|
|
||||||
instance_id,
|
|
||||||
user_id,
|
|
||||||
)
|
|
||||||
await websocket.close(code=4003, reason="Forbidden")
|
|
||||||
return
|
|
||||||
|
|
||||||
if instance.status != "running" or not instance.container_id:
|
|
||||||
logger.warning(
|
|
||||||
"Instance %s not running (status=%s, container_id=%s)",
|
|
||||||
instance_id,
|
|
||||||
instance.status,
|
|
||||||
instance.container_id,
|
|
||||||
)
|
|
||||||
await websocket.close(code=4004, reason="Instance not running")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Creating terminal session for instance %s (container_id=%s)",
|
|
||||||
instance_id,
|
|
||||||
instance.container_id,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
session = await terminal_manager.create_session(
|
|
||||||
instance_uuid,
|
|
||||||
instance.container_id,
|
|
||||||
websocket,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Terminal session created successfully for instance %s",
|
|
||||||
instance_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send connected status
|
|
||||||
await websocket.send_json({"type": "status", "status": "connected"})
|
|
||||||
|
|
||||||
# Monitor session health and echo state
|
|
||||||
while session.is_alive() and not session.closed:
|
|
||||||
# Check echo state periodically
|
|
||||||
new_echo_state = await session.check_echo_state()
|
|
||||||
if new_echo_state is not None:
|
|
||||||
await websocket.send_json(
|
|
||||||
{"type": "set_echo_state", "enabled": new_echo_state},
|
|
||||||
)
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
# Session ended — determine reason and notify client
|
|
||||||
exit_reason = session.get_exit_reason() or "process_exit"
|
|
||||||
await websocket.send_json({"type": "session_ended", "reason": exit_reason})
|
|
||||||
await websocket.close(code=1000, reason=f"Session ended: {exit_reason}")
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Terminal session error for instance %s",
|
|
||||||
instance_id,
|
|
||||||
)
|
|
||||||
await websocket.close(code=4000, reason="Terminal session error")
|
|
||||||
finally:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_user_from_websocket(
|
|
||||||
websocket: WebSocket,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
) -> uuid.UUID | None:
|
|
||||||
"""Extract and validate user ID from session cookie in WebSocket.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
websocket: The WebSocket connection.
|
|
||||||
db_session: Database session.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The user's UUID if authenticated, None otherwise.
|
|
||||||
|
|
||||||
"""
|
|
||||||
from src.auth.session import decode_session_cookie
|
|
||||||
from src.config import Settings
|
|
||||||
|
|
||||||
session_cookie = websocket.cookies.get("session")
|
|
||||||
if not session_cookie:
|
|
||||||
return None
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
try:
|
|
||||||
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
|
||||||
return uuid.UUID(str(payload["user_id"]))
|
|
||||||
except (ValueError, KeyError):
|
|
||||||
return None
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""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}
|
||||||
@@ -9,9 +9,9 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.tool_definition_manifest import ToolDefinitionManifest
|
from src.models import ToolDefinitionManifest
|
||||||
from src.models.tool_type 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())
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,18 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import yaml
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
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_current_user, get_db_session
|
from src.api.tool.tool_types_validation import (
|
||||||
from src.models.tool_type import ToolType
|
check_port_exposed,
|
||||||
|
validate_compose_yaml,
|
||||||
|
validate_required_variables,
|
||||||
|
)
|
||||||
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
|
from src.models import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.schemas.tool_type import (
|
from src.schemas.tool import (
|
||||||
ToolTypeCreate,
|
ToolTypeCreate,
|
||||||
ToolTypeResponse,
|
ToolTypeResponse,
|
||||||
ToolTypeUpdate,
|
ToolTypeUpdate,
|
||||||
@@ -38,7 +42,7 @@ async def _require_admin(user: User) -> None:
|
|||||||
)
|
)
|
||||||
async def create_tool_type(
|
async def create_tool_type(
|
||||||
data: ToolTypeCreate,
|
data: ToolTypeCreate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ToolType:
|
) -> ToolType:
|
||||||
"""Create a new tool type.
|
"""Create a new tool type.
|
||||||
@@ -51,6 +55,7 @@ async def create_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
The newly created tool type.
|
The newly created tool type.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
# Check for duplicate name
|
# Check for duplicate name
|
||||||
@@ -67,13 +72,16 @@ async def create_tool_type(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
default_port=data.default_port,
|
default_port=data.default_port,
|
||||||
definition_type=data.definition_type,
|
definition_type=data.definition_type,
|
||||||
|
manifest_id=data.manifest_id,
|
||||||
compose_template=data.compose_template,
|
compose_template=data.compose_template,
|
||||||
dockerfile_template=data.dockerfile_template,
|
dockerfile_template=data.dockerfile_template,
|
||||||
build_context=data.build_context,
|
build_context=data.build_context,
|
||||||
readiness_probe=data.readiness_probe,
|
readiness_probe=data.readiness_probe,
|
||||||
|
startup_command=data.startup_command,
|
||||||
required_variables=data.required_variables,
|
required_variables=data.required_variables,
|
||||||
category=data.category,
|
category=data.category,
|
||||||
interfaces=data.interfaces,
|
interface_type=data.interface_type,
|
||||||
|
requires_port=data.requires_port,
|
||||||
created_by_id=user.id,
|
created_by_id=user.id,
|
||||||
)
|
)
|
||||||
session.add(tool_type)
|
session.add(tool_type)
|
||||||
@@ -89,7 +97,7 @@ async def create_tool_type(
|
|||||||
description="List all available tool types including built-in and custom ones.",
|
description="List all available tool types including built-in and custom ones.",
|
||||||
)
|
)
|
||||||
async def list_tool_types(
|
async def list_tool_types(
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[ToolType]:
|
) -> list[ToolType]:
|
||||||
"""List all tool types.
|
"""List all tool types.
|
||||||
@@ -101,6 +109,7 @@ async def list_tool_types(
|
|||||||
Returns:
|
Returns:
|
||||||
List of all tool types ordered by name.
|
List of all tool types ordered by name.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
result = await session.execute(select(ToolType).order_by(ToolType.name))
|
result = await session.execute(select(ToolType).order_by(ToolType.name))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@@ -113,7 +122,7 @@ async def list_tool_types(
|
|||||||
)
|
)
|
||||||
async def get_tool_type(
|
async def get_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ToolType:
|
) -> ToolType:
|
||||||
"""Get a specific tool type by ID.
|
"""Get a specific tool type by ID.
|
||||||
@@ -126,6 +135,7 @@ async def get_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
The requested tool type.
|
The requested tool type.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -143,7 +153,7 @@ async def get_tool_type(
|
|||||||
async def update_tool_type(
|
async def update_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
data: ToolTypeUpdate,
|
data: ToolTypeUpdate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ToolType:
|
) -> ToolType:
|
||||||
"""Update a tool type.
|
"""Update a tool type.
|
||||||
@@ -157,6 +167,7 @@ async def update_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated tool type.
|
The updated tool type.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
@@ -165,16 +176,13 @@ async def update_tool_type(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
if tool_type.is_builtin:
|
# Built-in tool types can now be modified
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="cannot modify built-in tool types",
|
|
||||||
)
|
|
||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
# Validate port if being updated
|
# Validate port if being updated
|
||||||
if "default_port" in update_data:
|
requires_port = update_data.get("requires_port", tool_type.requires_port)
|
||||||
|
if "default_port" in update_data and requires_port:
|
||||||
new_port = update_data["default_port"]
|
new_port = update_data["default_port"]
|
||||||
if new_port <= 0 or new_port > 65535:
|
if new_port <= 0 or new_port > 65535:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -188,62 +196,35 @@ async def update_tool_type(
|
|||||||
template = update_data.get("compose_template", tool_type.compose_template)
|
template = update_data.get("compose_template", tool_type.compose_template)
|
||||||
if template:
|
if template:
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(template)
|
parsed = validate_compose_yaml(template)
|
||||||
except yaml.YAMLError:
|
if not check_port_exposed(parsed, new_port):
|
||||||
parsed = None
|
|
||||||
|
|
||||||
if parsed and isinstance(parsed, dict) and "services" in parsed:
|
|
||||||
port_str = str(new_port)
|
|
||||||
port_exposed = False
|
|
||||||
for service_config in parsed["services"].values():
|
|
||||||
if (
|
|
||||||
isinstance(service_config, dict)
|
|
||||||
and "ports" in service_config
|
|
||||||
):
|
|
||||||
for port_mapping in service_config["ports"]:
|
|
||||||
if (
|
|
||||||
isinstance(port_mapping, str)
|
|
||||||
and port_str in port_mapping
|
|
||||||
):
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
elif (
|
|
||||||
isinstance(port_mapping, int)
|
|
||||||
and port_mapping == new_port
|
|
||||||
):
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
if port_exposed:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not port_exposed:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Port {new_port} is not exposed in the compose template",
|
detail=f"Port {new_port} is not exposed in the compose template",
|
||||||
)
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
# Validate required variables for compose definitions
|
# Validate required variables for compose definitions
|
||||||
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
||||||
if definition_type == "compose":
|
if definition_type == "compose":
|
||||||
if "required_variables" in update_data and "compose_template" in update_data:
|
if "required_variables" in update_data and "compose_template" in update_data:
|
||||||
template = update_data["compose_template"]
|
validate_required_variables(
|
||||||
for var in update_data["required_variables"]:
|
update_data["compose_template"], update_data["required_variables"]
|
||||||
placeholder = f"{{{{{var}}}}}"
|
)
|
||||||
if placeholder not in template:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Required variable '{var}' not found in compose template",
|
|
||||||
)
|
|
||||||
elif "required_variables" in update_data:
|
elif "required_variables" in update_data:
|
||||||
template = tool_type.compose_template
|
template = tool_type.compose_template
|
||||||
if template:
|
if template:
|
||||||
for var in update_data["required_variables"]:
|
validate_required_variables(template, update_data["required_variables"])
|
||||||
placeholder = f"{{{{{var}}}}}"
|
|
||||||
if placeholder not in template:
|
# When switching to manifest, clear legacy templates
|
||||||
raise HTTPException(
|
if definition_type == "manifest":
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
if "manifest_id" in update_data:
|
||||||
detail=f"Required variable '{var}' not found in compose template",
|
tool_type.manifest_id = update_data["manifest_id"]
|
||||||
)
|
tool_type.compose_template = None
|
||||||
|
tool_type.dockerfile_template = None
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(tool_type, field, value)
|
setattr(tool_type, field, value)
|
||||||
@@ -260,7 +241,7 @@ async def update_tool_type(
|
|||||||
)
|
)
|
||||||
async def validate_tool_type_template(
|
async def validate_tool_type_template(
|
||||||
data: ToolTypeValidateRequest,
|
data: ToolTypeValidateRequest,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Validate a tool type template syntax.
|
"""Validate a tool type template syntax.
|
||||||
@@ -273,6 +254,7 @@ async def validate_tool_type_template(
|
|||||||
Returns:
|
Returns:
|
||||||
Validation result with success status and any errors.
|
Validation result with success status and any errors.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
@@ -281,15 +263,9 @@ async def validate_tool_type_template(
|
|||||||
errors.append("Compose template is required")
|
errors.append("Compose template is required")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(data.compose_template)
|
validate_compose_yaml(data.compose_template)
|
||||||
if not isinstance(parsed, dict):
|
except ValueError as e:
|
||||||
errors.append("Compose template must be a YAML mapping")
|
errors.append(str(e))
|
||||||
elif "services" not in parsed:
|
|
||||||
errors.append("Compose template must contain 'services' key")
|
|
||||||
elif not parsed["services"]:
|
|
||||||
errors.append("Compose template must define at least one service")
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
errors.append(f"Invalid YAML: {e}")
|
|
||||||
|
|
||||||
elif data.definition_type == "dockerfile":
|
elif data.definition_type == "dockerfile":
|
||||||
if not data.dockerfile_template:
|
if not data.dockerfile_template:
|
||||||
@@ -297,8 +273,11 @@ async def validate_tool_type_template(
|
|||||||
elif not data.dockerfile_template.strip().startswith("FROM"):
|
elif not data.dockerfile_template.strip().startswith("FROM"):
|
||||||
errors.append("Dockerfile must start with a FROM instruction")
|
errors.append("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
elif data.definition_type == "manifest":
|
||||||
|
pass # Manifest validation is handled separately
|
||||||
|
|
||||||
else:
|
else:
|
||||||
errors.append("definition_type must be 'compose' or 'dockerfile'")
|
errors.append("definition_type must be 'compose', 'dockerfile', or 'manifest'")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"valid": len(errors) == 0,
|
"valid": len(errors) == 0,
|
||||||
@@ -313,7 +292,7 @@ async def validate_tool_type_template(
|
|||||||
)
|
)
|
||||||
async def validate_tool_type(
|
async def validate_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Validate a tool type's template syntax.
|
"""Validate a tool type's template syntax.
|
||||||
@@ -326,6 +305,7 @@ async def validate_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
Validation result with success status and any errors.
|
Validation result with success status and any errors.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -339,15 +319,9 @@ async def validate_tool_type(
|
|||||||
errors.append("Compose template is empty")
|
errors.append("Compose template is empty")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(tool_type.compose_template)
|
validate_compose_yaml(tool_type.compose_template)
|
||||||
if not isinstance(parsed, dict):
|
except ValueError as e:
|
||||||
errors.append("Compose template must be a YAML mapping")
|
errors.append(str(e))
|
||||||
elif "services" not in parsed:
|
|
||||||
errors.append("Compose template must contain 'services' key")
|
|
||||||
elif not parsed["services"]:
|
|
||||||
errors.append("Compose template must define at least one service")
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
errors.append(f"Invalid YAML: {e}")
|
|
||||||
|
|
||||||
elif tool_type.definition_type == "dockerfile":
|
elif tool_type.definition_type == "dockerfile":
|
||||||
if not tool_type.dockerfile_template:
|
if not tool_type.dockerfile_template:
|
||||||
@@ -355,6 +329,10 @@ async def validate_tool_type(
|
|||||||
elif not tool_type.dockerfile_template.strip().startswith("FROM"):
|
elif not tool_type.dockerfile_template.strip().startswith("FROM"):
|
||||||
errors.append("Dockerfile must start with a FROM instruction")
|
errors.append("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
elif tool_type.definition_type == "manifest":
|
||||||
|
if not tool_type.manifest_id:
|
||||||
|
errors.append("Manifest reference is missing")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"valid": len(errors) == 0,
|
"valid": len(errors) == 0,
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
@@ -369,7 +347,7 @@ async def validate_tool_type(
|
|||||||
)
|
)
|
||||||
async def delete_tool_type(
|
async def delete_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a tool type.
|
"""Delete a tool type.
|
||||||
@@ -382,6 +360,7 @@ async def delete_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
None with 204 status code.
|
None with 204 status code.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
@@ -390,11 +369,7 @@ async def delete_tool_type(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
if tool_type.is_builtin:
|
# Built-in tool types can now be deleted
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="cannot delete built-in tool types",
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.delete(tool_type)
|
await session.delete(tool_type)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
"""Tool instance API endpoints."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user, get_db_session, get_owned_project
|
|
||||||
from src.models.git_repository import GitRepository
|
|
||||||
from src.models.project import Project
|
|
||||||
from src.models.tool_instance import ToolInstance
|
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
from src.models.user import User
|
|
||||||
from src.schemas.tool_instance import CreateInstanceRequest
|
|
||||||
from src.services import instance_lifecycle as lifecycle
|
|
||||||
from src.services.docker import container as container_svc
|
|
||||||
from src.services.docker import tunnel as tunnel_svc
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
|
||||||
async def _get_instance(session: AsyncSession, instance_id: uuid.UUID, repo_id: uuid.UUID) -> ToolInstance:
|
|
||||||
instance = await session.get(ToolInstance, instance_id)
|
|
||||||
if instance is None or instance.repository_id != repo_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="instance not found")
|
|
||||||
return instance
|
|
||||||
async def _get_repo(session: AsyncSession, repo_id: uuid.UUID, project_id: uuid.UUID) -> GitRepository:
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
|
||||||
if repo is None or repo.project_id != project_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
|
||||||
return repo
|
|
||||||
# ── Endpoints ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/instances")
|
|
||||||
async def create_instance(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: CreateInstanceRequest,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Create a new tool instance."""
|
|
||||||
repo = await _get_repo(session, repo_id, project_id)
|
|
||||||
tool_type = await session.get(ToolType, uuid.UUID(data.tool_type_id))
|
|
||||||
if tool_type is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
|
||||||
|
|
||||||
selected_profile = None
|
|
||||||
if data.config_profile_id:
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
selected_profile = await session.get(ConfigProfile, uuid.UUID(data.config_profile_id))
|
|
||||||
if selected_profile is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config profile not found")
|
|
||||||
if selected_profile.user_id != user.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="config profile does not belong to user")
|
|
||||||
|
|
||||||
instance = await lifecycle.create_new_instance(
|
|
||||||
session, project, repo, tool_type, user, data.display_name, selected_profile
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"id": str(instance.id),
|
|
||||||
"name": instance.name,
|
|
||||||
"display_name": instance.display_name,
|
|
||||||
"tool_type_id": str(instance.tool_type_id),
|
|
||||||
"status": instance.status,
|
|
||||||
"config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None,
|
|
||||||
"created_at": instance.created_at.isoformat(),
|
|
||||||
}
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/instances")
|
|
||||||
async def list_instances(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""List all tool instances for a repository."""
|
|
||||||
await _get_repo(session, repo_id, project_id)
|
|
||||||
from sqlalchemy import select
|
|
||||||
result = await session.execute(
|
|
||||||
select(ToolInstance)
|
|
||||||
.where(ToolInstance.repository_id == repo_id, ToolInstance.owner_id == user.id)
|
|
||||||
.order_by(ToolInstance.created_at.desc())
|
|
||||||
)
|
|
||||||
instances = []
|
|
||||||
for i in result.scalars().all():
|
|
||||||
tt = await session.get(ToolType, i.tool_type_id)
|
|
||||||
instances.append({
|
|
||||||
"id": str(i.id), "name": i.name, "display_name": i.display_name,
|
|
||||||
"tool_type_id": str(i.tool_type_id), "tool_type_name": tt.name if tt else "unknown",
|
|
||||||
"tool_type_interfaces": tt.interfaces if tt else [],
|
|
||||||
"status": i.status, "url": i.url, "port": i.port,
|
|
||||||
"config_profile_id": str(i.selected_profile_id) if i.selected_profile_id else None,
|
|
||||||
"created_at": i.created_at.isoformat(),
|
|
||||||
})
|
|
||||||
return {"instances": instances}
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}")
|
|
||||||
async def get_instance(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
instance_id: uuid.UUID,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Get a specific instance with real-time Docker status."""
|
|
||||||
from datetime import datetime
|
|
||||||
instance = await _get_instance(session, instance_id, repo_id)
|
|
||||||
if instance.container_id:
|
|
||||||
docker_status = container_svc.get_container_status(instance.container_id)
|
|
||||||
if docker_status == "running" and instance.status != "running":
|
|
||||||
instance.status = "running"
|
|
||||||
await session.commit()
|
|
||||||
elif docker_status == "exited" and instance.status == "running":
|
|
||||||
instance.status = "stopped"
|
|
||||||
instance.last_stopped_at = datetime.now()
|
|
||||||
await session.commit()
|
|
||||||
return {
|
|
||||||
"id": str(instance.id), "name": instance.name, "display_name": instance.display_name,
|
|
||||||
"tool_type_id": str(instance.tool_type_id), "status": instance.status,
|
|
||||||
"container_id": instance.container_id, "compose_path": instance.compose_path,
|
|
||||||
"url": instance.url, "port": instance.port,
|
|
||||||
"config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None,
|
|
||||||
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
|
|
||||||
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
|
|
||||||
"created_at": instance.created_at.isoformat(),
|
|
||||||
}
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/start")
|
|
||||||
async def start_instance(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
instance_id: uuid.UUID,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Start a tool instance."""
|
|
||||||
instance = await _get_instance(session, instance_id, repo_id)
|
|
||||||
return await lifecycle.start_existing_instance(session, instance, user, project_id)
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop")
|
|
||||||
async def stop_instance(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
instance_id: uuid.UUID,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Stop a running tool instance."""
|
|
||||||
instance = await _get_instance(session, instance_id, repo_id)
|
|
||||||
await lifecycle.stop_existing_instance(session, instance)
|
|
||||||
return {"status": instance.status}
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart")
|
|
||||||
async def restart_instance(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
instance_id: uuid.UUID,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Restart a tool instance."""
|
|
||||||
instance = await _get_instance(session, instance_id, repo_id)
|
|
||||||
return await lifecycle.restart_existing_instance(session, instance, user, project_id)
|
|
||||||
@router.delete("/{project_id}/repositories/{repo_id}/instances/{instance_id}")
|
|
||||||
async def delete_instance(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
instance_id: uuid.UUID,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> None:
|
|
||||||
"""Delete a tool instance."""
|
|
||||||
instance = await _get_instance(session, instance_id, repo_id)
|
|
||||||
await lifecycle.delete_existing_instance(session, instance)
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs")
|
|
||||||
async def get_instance_logs(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
instance_id: uuid.UUID,
|
|
||||||
tail: int = 100,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Get container logs for an instance."""
|
|
||||||
instance = await _get_instance(session, instance_id, repo_id)
|
|
||||||
if not instance.container_id:
|
|
||||||
return {"logs": "No container running"}
|
|
||||||
return {"logs": container_svc.get_container_logs(instance.container_id, tail)}
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/recreate-tunnel")
|
|
||||||
async def recreate_tunnel_endpoint(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
instance_id: uuid.UUID,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Recreate the temporary tunnel for an instance."""
|
|
||||||
instance = await _get_instance(session, instance_id, repo_id)
|
|
||||||
if instance.status != "running":
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="instance must be running")
|
|
||||||
|
|
||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
|
||||||
instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080
|
|
||||||
|
|
||||||
try:
|
|
||||||
tunnel_info = tunnel_svc.recreate_tunnel(
|
|
||||||
container_name=instance.container_name or instance.name,
|
|
||||||
port=instance_port,
|
|
||||||
old_pid=instance.tunnel_id,
|
|
||||||
)
|
|
||||||
instance.tunnel_id = tunnel_info["pid"]
|
|
||||||
instance.public_url = tunnel_info["url"]
|
|
||||||
instance.url = tunnel_info["url"]
|
|
||||||
await session.commit()
|
|
||||||
return {"status": "healthy", "url": instance.url}
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Failed to recreate tunnel")
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to recreate tunnel: {exc}")
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}/health")
|
|
||||||
async def check_instance_tunnel_health(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
instance_id: uuid.UUID,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Check tunnel health for an instance."""
|
|
||||||
instance = await _get_instance(session, instance_id, repo_id)
|
|
||||||
if not instance.url or instance.status != "running":
|
|
||||||
return {"healthy": False, "status_code": None, "error": "instance not running"}
|
|
||||||
return tunnel_svc.check_tunnel_health(instance.url)
|
|
||||||
@router.api_route(
|
|
||||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
|
||||||
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
|
|
||||||
)
|
|
||||||
async def proxy_to_instance(
|
|
||||||
request: Request,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
instance_id: uuid.UUID,
|
|
||||||
path: str = "",
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> Response:
|
|
||||||
"""Proxy HTTP requests to a running tool instance."""
|
|
||||||
instance = await _get_instance(session, instance_id, repo_id)
|
|
||||||
if instance.owner_id != user.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not authorized")
|
|
||||||
if instance.status != "running" or not instance.container_name:
|
|
||||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="instance is not running")
|
|
||||||
|
|
||||||
target_url = f"http://{instance.container_name}:{instance.port}"
|
|
||||||
if path:
|
|
||||||
target_url += f"/{path}"
|
|
||||||
query = str(request.query_params)
|
|
||||||
if query:
|
|
||||||
target_url += f"?{query}"
|
|
||||||
|
|
||||||
headers = dict(request.headers)
|
|
||||||
headers.pop("host", None)
|
|
||||||
headers.pop("cookie", None)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
body = await request.body()
|
|
||||||
response = await client.request(
|
|
||||||
method=request.method, url=target_url, headers=headers,
|
|
||||||
content=body, follow_redirects=False, timeout=30.0,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Proxy error: %s", exc)
|
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"failed to reach instance: {exc}")
|
|
||||||
|
|
||||||
resp_headers = dict(response.headers)
|
|
||||||
for h in ["content-encoding", "transfer-encoding", "connection"]:
|
|
||||||
resp_headers.pop(h, None)
|
|
||||||
return Response(content=response.content, status_code=response.status_code, headers=resp_headers)
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""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,5 +1,5 @@
|
|||||||
|
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
|
||||||
@@ -8,21 +8,26 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
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_current_user, 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.ssh_key import SSHKey
|
from src.models import SSHKey
|
||||||
from src.models.user import User
|
from src.schemas.project import (
|
||||||
from src.schemas.ssh_key import SSHKeyCreate, SSHKeyResponse
|
SSHKeyCreate,
|
||||||
|
SSHKeyResponse,
|
||||||
|
SignPayloadRequest,
|
||||||
|
SignatureResponse,
|
||||||
|
VerifySignatureRequest,
|
||||||
|
VerifySignatureResponse,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _get_fernet() -> Fernet:
|
def _get_fernet() -> Fernet:
|
||||||
"""Generate a valid Fernet key from the session secret."""
|
"""Generate a valid Fernet key from the session secret."""
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
# Derive a 32-byte key from the session secret using SHA256
|
# Derive a 32-byte key from the session secret using SHA256
|
||||||
key_bytes = hashlib.sha256(settings.session_secret.encode()).digest()
|
key_bytes = hashlib.sha256(settings.session_secret.encode()).digest()
|
||||||
@@ -63,7 +68,7 @@ def generate_ssh_key_pair() -> tuple[str, str]:
|
|||||||
)
|
)
|
||||||
async def create_ssh_key(
|
async def create_ssh_key(
|
||||||
data: SSHKeyCreate,
|
data: SSHKeyCreate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> SSHKey:
|
) -> SSHKey:
|
||||||
"""Create a new SSH key pair.
|
"""Create a new SSH key pair.
|
||||||
@@ -76,6 +81,7 @@ async def create_ssh_key(
|
|||||||
Returns:
|
Returns:
|
||||||
The newly created SSH key with public key exposed.
|
The newly created SSH key with public key exposed.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
private_key, public_key = generate_ssh_key_pair()
|
private_key, public_key = generate_ssh_key_pair()
|
||||||
|
|
||||||
fernet = _get_fernet()
|
fernet = _get_fernet()
|
||||||
@@ -100,7 +106,7 @@ async def create_ssh_key(
|
|||||||
description="List all SSH keys for the authenticated user.",
|
description="List all SSH keys for the authenticated user.",
|
||||||
)
|
)
|
||||||
async def list_ssh_keys(
|
async def list_ssh_keys(
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[SSHKey]:
|
) -> list[SSHKey]:
|
||||||
"""List all SSH keys for the authenticated user.
|
"""List all SSH keys for the authenticated user.
|
||||||
@@ -112,6 +118,7 @@ async def list_ssh_keys(
|
|||||||
Returns:
|
Returns:
|
||||||
List of SSH keys owned by the user.
|
List of SSH keys owned by the user.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
|
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@@ -124,7 +131,7 @@ async def list_ssh_keys(
|
|||||||
)
|
)
|
||||||
async def delete_ssh_key(
|
async def delete_ssh_key(
|
||||||
key_id: uuid.UUID,
|
key_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete an SSH key.
|
"""Delete an SSH key.
|
||||||
@@ -137,9 +144,93 @@ async def delete_ssh_key(
|
|||||||
Returns:
|
Returns:
|
||||||
None with 204 status code.
|
None with 204 status code.
|
||||||
"""
|
"""
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{key_id}/sign",
|
||||||
|
response_model=SignatureResponse,
|
||||||
|
summary="Sign payload",
|
||||||
|
description="Sign a payload using the SSH private key.",
|
||||||
|
)
|
||||||
|
async def sign_payload(
|
||||||
|
key_id: uuid.UUID,
|
||||||
|
data: SignPayloadRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> SignatureResponse:
|
||||||
|
"""Sign a payload with an SSH key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: UUID of the SSH key to use for signing.
|
||||||
|
data: Sign request containing the payload string.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64-encoded Ed25519 signature.
|
||||||
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
ssh_key = await session.get(SSHKey, key_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"
|
||||||
|
)
|
||||||
|
|
||||||
|
fernet = _get_fernet()
|
||||||
|
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
|
||||||
|
private_key = serialization.load_ssh_private_key(
|
||||||
|
private_key_pem.encode(), password=None
|
||||||
|
)
|
||||||
|
|
||||||
|
signature = private_key.sign(data.payload.encode())
|
||||||
|
return SignatureResponse(signature=base64.b64encode(signature).decode())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{key_id}/verify",
|
||||||
|
response_model=VerifySignatureResponse,
|
||||||
|
summary="Verify signature",
|
||||||
|
description="Verify a signature against a payload using the SSH public key.",
|
||||||
|
)
|
||||||
|
async def verify_signature(
|
||||||
|
key_id: uuid.UUID,
|
||||||
|
data: VerifySignatureRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> VerifySignatureResponse:
|
||||||
|
"""Verify a signature with an SSH key's public key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: UUID of the SSH key to use for verification.
|
||||||
|
data: Verify request containing payload and base64-encoded signature.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Whether the signature is valid.
|
||||||
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
ssh_key = await session.get(SSHKey, key_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"
|
||||||
|
)
|
||||||
|
|
||||||
|
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
|
||||||
|
|
||||||
|
try:
|
||||||
|
signature = base64.b64decode(data.signature)
|
||||||
|
public_key.verify(signature, data.payload.encode())
|
||||||
|
return VerifySignatureResponse(valid=True)
|
||||||
|
except Exception:
|
||||||
|
return VerifySignatureResponse(valid=False)
|
||||||
@@ -2,13 +2,10 @@ 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 sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.tool_instance import ToolInstance
|
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.schemas.tool_instance import SessionItemResponse, SessionListResponse
|
|
||||||
from src.schemas.user import UserProfileResponse, UserProfileUpdate
|
from src.schemas.user import UserProfileResponse, UserProfileUpdate
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
@@ -26,7 +23,7 @@ MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
|||||||
description="Retrieve the profile of the currently authenticated user.",
|
description="Retrieve the profile of the currently authenticated user.",
|
||||||
)
|
)
|
||||||
async def get_profile(
|
async def get_profile(
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Get the current user's profile.
|
"""Get the current user's profile.
|
||||||
@@ -38,7 +35,7 @@ async def get_profile(
|
|||||||
Returns:
|
Returns:
|
||||||
The user's profile information.
|
The user's profile information.
|
||||||
"""
|
"""
|
||||||
return user
|
return await _get_user(session, user_id)
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
@@ -49,7 +46,7 @@ async def get_profile(
|
|||||||
)
|
)
|
||||||
async def update_profile(
|
async def update_profile(
|
||||||
data: UserProfileUpdate,
|
data: UserProfileUpdate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Update the current user's profile.
|
"""Update the current user's profile.
|
||||||
@@ -62,6 +59,7 @@ async def update_profile(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated user profile.
|
The updated user profile.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
if len(data.name.strip()) == 0:
|
if len(data.name.strip()) == 0:
|
||||||
@@ -90,7 +88,7 @@ async def update_profile(
|
|||||||
)
|
)
|
||||||
async def upload_avatar(
|
async def upload_avatar(
|
||||||
file: UploadFile,
|
file: UploadFile,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Upload a profile avatar image.
|
"""Upload a profile avatar image.
|
||||||
@@ -103,6 +101,7 @@ async def upload_avatar(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated user profile with new avatar URL.
|
The updated user profile with new avatar URL.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
|
||||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -137,41 +136,3 @@ async def upload_avatar(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/me/sessions",
|
|
||||||
response_model=SessionListResponse,
|
|
||||||
summary="Get current user sessions",
|
|
||||||
description="Retrieve all tool instances (sessions) for the authenticated user.",
|
|
||||||
)
|
|
||||||
async def get_user_sessions(
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> SessionListResponse:
|
|
||||||
"""Return all tool instances for the current user with related names."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(ToolInstance)
|
|
||||||
.where(ToolInstance.owner_id == user.id)
|
|
||||||
.order_by(ToolInstance.created_at.desc())
|
|
||||||
)
|
|
||||||
instances = result.scalars().all()
|
|
||||||
|
|
||||||
sessions = [
|
|
||||||
SessionItemResponse(
|
|
||||||
id=str(inst.id),
|
|
||||||
display_name=inst.display_name,
|
|
||||||
tool_type_name=inst.tool_type.display_name if inst.tool_type else "Unknown",
|
|
||||||
tool_icon=None,
|
|
||||||
tool_type_interfaces=inst.tool_type.interfaces if inst.tool_type else [],
|
|
||||||
repository_name=inst.repository.name if inst.repository else "Unknown",
|
|
||||||
repository_id=str(inst.repository_id),
|
|
||||||
project_name=inst.project.name if inst.project else "Unknown",
|
|
||||||
project_id=str(inst.project_id),
|
|
||||||
status=inst.status,
|
|
||||||
url=inst.url,
|
|
||||||
)
|
|
||||||
for inst in instances
|
|
||||||
]
|
|
||||||
|
|
||||||
return SessionListResponse(sessions=sessions)
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
@@ -6,8 +6,8 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.workspace 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:
|
||||||
@@ -6,8 +6,8 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.workspace 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")
|
||||||
|
|
||||||
+2
-2
@@ -7,8 +7,8 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.tool_instance import ToolInstance
|
from src.models import ToolInstance
|
||||||
from src.models.workspace import Workspace
|
from src.models import Workspace
|
||||||
|
|
||||||
router = APIRouter(prefix="/workspaces/{workspace_id}/instances")
|
router = APIRouter(prefix="/workspaces/{workspace_id}/instances")
|
||||||
|
|
||||||
@@ -9,10 +9,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
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.git_repository import GitRepository
|
from src.models import GitRepository
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models import ToolInstance
|
||||||
from src.models.workspace 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__)
|
||||||
|
|
||||||
@@ -50,17 +50,25 @@ async def get_current_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def get_owned_project(
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
|
"""Fetch a user by ID or raise 401 if not found."""
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_owned_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID,
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession,
|
||||||
) -> Project:
|
) -> "Project":
|
||||||
"""Fetch a project and verify ownership.
|
"""Fetch a project and verify ownership.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_id: UUID of the project (injected from path parameter).
|
project_id: UUID of the project.
|
||||||
user: The currently authenticated user.
|
user_id: ID of the authenticated user.
|
||||||
db_session: Database session.
|
session: Database session.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The project if found and owned by the user.
|
The project if found and owned by the user.
|
||||||
@@ -68,9 +76,11 @@ async def get_owned_project(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: 404 if project not found, 403 if user is not the owner.
|
HTTPException: 404 if project not found, 403 if user is not the owner.
|
||||||
"""
|
"""
|
||||||
project = await db_session.get(Project, project_id)
|
from src.models.project import Project
|
||||||
|
|
||||||
|
project = await session.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
||||||
if project.owner_id != user.id:
|
if project.owner_id != user_id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
||||||
return project
|
return project
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|
||||||
|
|||||||
+59
-14
@@ -6,20 +6,34 @@ from fastapi.exceptions import RequestValidationError
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
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.dashboard import router as dashboard_router
|
from src.api.config import config_profiles_router, user_config_router
|
||||||
from src.api.git_repositories import router as git_repositories_router
|
from src.api.project import git_repositories_router, projects_router
|
||||||
from src.api.health import router as health_router
|
from src.api.system import (
|
||||||
from src.api.projects import router as projects_router
|
dashboard_router,
|
||||||
from src.api.ssh_keys import router as ssh_keys_router
|
events_router,
|
||||||
from src.api.terminal import router as terminal_router
|
health_router,
|
||||||
from src.api.instance_proxy import router as instance_proxy_router
|
instance_proxy_router,
|
||||||
from src.api.config_profiles import router as config_profiles_router
|
notifications_router,
|
||||||
from src.api.tool_instances import router as tool_instances_router
|
terminal_router,
|
||||||
from src.api.tool_types import router as tool_types_router
|
)
|
||||||
from src.api.user_config import router as user_config_router
|
from src.api.tool import (
|
||||||
from src.api.users import router as users_router
|
sessions_router,
|
||||||
|
tool_definitions_router,
|
||||||
|
tool_instances_router,
|
||||||
|
tool_types_router,
|
||||||
|
)
|
||||||
|
from src.api.user import auth_router, ssh_keys_router, users_router
|
||||||
|
from src.api.workspace import (
|
||||||
|
all_workspaces_router,
|
||||||
|
workspace_files_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 TerminalSessionModel # noqa: F401 – Alembic model discovery
|
||||||
from src.database import init_database
|
from src.database import init_database
|
||||||
from src.logging_config import (
|
from src.logging_config import (
|
||||||
ExceptionLoggingMiddleware,
|
ExceptionLoggingMiddleware,
|
||||||
@@ -27,6 +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.instance import InstanceEventBus, HealthMonitor
|
||||||
|
from src.services.shared import CorrelationIdMiddleware
|
||||||
|
|
||||||
# Configure logging early
|
# Configure logging early
|
||||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
@@ -51,6 +67,7 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.add_middleware(CorrelationIdMiddleware)
|
||||||
app.add_middleware(RequestLoggingMiddleware)
|
app.add_middleware(RequestLoggingMiddleware)
|
||||||
app.add_middleware(ExceptionLoggingMiddleware)
|
app.add_middleware(ExceptionLoggingMiddleware)
|
||||||
|
|
||||||
@@ -100,6 +117,11 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global services
|
||||||
|
_event_bus = InstanceEventBus()
|
||||||
|
_health_monitor = HealthMonitor(_event_bus)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def on_startup():
|
async def on_startup():
|
||||||
logger.info("Starting up Headquarter API...")
|
logger.info("Starting up Headquarter API...")
|
||||||
@@ -112,11 +134,25 @@ async def on_startup():
|
|||||||
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Seed built-in data
|
# Start background health monitor
|
||||||
|
_health_monitor.start()
|
||||||
|
logger.info("Health monitor started")
|
||||||
|
|
||||||
|
# Seed built-in tool types
|
||||||
await seed_builtin_tool_types()
|
await seed_builtin_tool_types()
|
||||||
|
logger.info("Built-in tool types seeded")
|
||||||
|
|
||||||
logger.info("Startup complete.")
|
logger.info("Startup complete.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def on_shutdown():
|
||||||
|
logger.info("Shutting down Headquarter API...")
|
||||||
|
_health_monitor.stop()
|
||||||
|
logger.info("Health monitor stopped")
|
||||||
|
logger.info("Shutdown complete.")
|
||||||
|
|
||||||
|
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
@@ -126,8 +162,17 @@ app.include_router(ssh_keys_router)
|
|||||||
app.include_router(git_repositories_router)
|
app.include_router(git_repositories_router)
|
||||||
app.include_router(user_config_router)
|
app.include_router(user_config_router)
|
||||||
app.include_router(tool_types_router)
|
app.include_router(tool_types_router)
|
||||||
|
app.include_router(tool_definitions_router)
|
||||||
app.include_router(config_profiles_router)
|
app.include_router(config_profiles_router)
|
||||||
app.include_router(tool_instances_router)
|
app.include_router(tool_instances_router)
|
||||||
|
app.include_router(sessions_router)
|
||||||
app.include_router(instance_proxy_router)
|
app.include_router(instance_proxy_router)
|
||||||
app.include_router(terminal_router)
|
app.include_router(terminal_router)
|
||||||
|
app.include_router(events_router)
|
||||||
|
app.include_router(notifications_router)
|
||||||
|
app.include_router(all_workspaces_router)
|
||||||
|
app.include_router(workspaces_router)
|
||||||
|
app.include_router(workspace_files_router)
|
||||||
|
app.include_router(workspace_git_router)
|
||||||
|
app.include_router(workspace_instances_router)
|
||||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||||
|
|||||||
@@ -1,25 +1,34 @@
|
|||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
from src.models.config_include import ConfigInclude
|
from src.models.config.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
from src.models.config_mount import ConfigMount
|
from src.models.project.git_repository import GitRepository
|
||||||
from src.models.config_profile import ConfigProfile
|
from src.models.project.project import Project
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.project.workspace import Workspace
|
||||||
from src.models.project import Project
|
from src.models.system.health_check import HealthCheck
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.system.instance_event import InstanceEvent
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models.system.notification import Notification
|
||||||
from src.models.tool_type import ToolType
|
from src.models.system.terminal_session import TerminalSessionModel
|
||||||
from src.models.user import User
|
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest
|
||||||
from src.models.user_config import UserConfig
|
from src.models.tool.tool_instance import ToolInstance
|
||||||
|
from src.models.tool.tool_type import ToolType
|
||||||
|
from src.models.user.ssh_key import SSHKey
|
||||||
|
from src.models.user.user import User
|
||||||
|
from src.models.user.user_config import UserConfig
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
"ConfigInclude",
|
|
||||||
"ConfigMount",
|
|
||||||
"ConfigProfile",
|
"ConfigProfile",
|
||||||
|
"ConfigProfileInclude",
|
||||||
"GitRepository",
|
"GitRepository",
|
||||||
|
"HealthCheck",
|
||||||
|
"InstanceEvent",
|
||||||
|
"Notification",
|
||||||
"Project",
|
"Project",
|
||||||
"SSHKey",
|
"SSHKey",
|
||||||
|
"TerminalSessionModel",
|
||||||
|
"ToolDefinitionManifest",
|
||||||
"ToolInstance",
|
"ToolInstance",
|
||||||
"ToolType",
|
"ToolType",
|
||||||
"User",
|
"User",
|
||||||
"UserConfig",
|
"UserConfig",
|
||||||
|
"Workspace",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Config models module."""
|
||||||
|
|
||||||
|
from src.models.config.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
|
|
||||||
|
__all__ = ["ConfigProfile", "ConfigProfileInclude"]
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
ForeignKey,
|
||||||
|
JSON,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models import ToolType
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_profiles"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
project_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
env_vars: Mapped[dict] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
) # {"VAR_NAME": "value", ...}
|
||||||
|
runtime_hints: Mapped[dict] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
) # {"start_command": "...", "working_dir": "...", ...}
|
||||||
|
mounts: Mapped[list] = mapped_column(
|
||||||
|
JSON, default=list, nullable=False
|
||||||
|
) # [{"target": "/path", "mode": "rw", "files": {"rel/path": "content"}}, ...]
|
||||||
|
files: Mapped[dict] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
) # {"rel/path": "content", ...}
|
||||||
|
git_mounts: Mapped[list] = mapped_column(
|
||||||
|
JSON, default=list, nullable=False
|
||||||
|
) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship()
|
||||||
|
project: Mapped["Project | None"] = relationship()
|
||||||
|
tool_type: Mapped["ToolType | None"] = relationship()
|
||||||
|
includes: Mapped[list["ConfigProfileInclude"]] = relationship(
|
||||||
|
"ConfigProfileInclude",
|
||||||
|
foreign_keys="ConfigProfileInclude.profile_id",
|
||||||
|
order_by="ConfigProfileInclude.order_index",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_profile_includes"
|
||||||
|
|
||||||
|
profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
included_profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[profile_id],
|
||||||
|
back_populates="includes",
|
||||||
|
)
|
||||||
|
included_profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[included_profile_id],
|
||||||
|
)
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Integer, UniqueConstraint
|
|
||||||
from sqlalchemy import Uuid as UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|
||||||
__tablename__ = "config_includes"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
|
|
||||||
)
|
|
||||||
|
|
||||||
profile_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
included_profile_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
|
|
||||||
profile: Mapped["ConfigProfile"] = relationship(
|
|
||||||
"ConfigProfile",
|
|
||||||
foreign_keys=[profile_id],
|
|
||||||
back_populates="includes",
|
|
||||||
)
|
|
||||||
included_profile: Mapped["ConfigProfile"] = relationship(
|
|
||||||
"ConfigProfile",
|
|
||||||
foreign_keys=[included_profile_id],
|
|
||||||
)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Integer, JSON, String
|
|
||||||
from sqlalchemy import Uuid as UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|
||||||
__tablename__ = "config_mounts"
|
|
||||||
|
|
||||||
profile_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
target_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
|
||||||
mode: Mapped[str] = mapped_column(String(10), nullable=False, default="rw")
|
|
||||||
files: Mapped[dict[str, str] | None] = mapped_column(
|
|
||||||
JSON, default=dict, nullable=True
|
|
||||||
)
|
|
||||||
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
|
|
||||||
profile: Mapped["ConfigProfile"] = relationship(
|
|
||||||
"ConfigProfile",
|
|
||||||
foreign_keys=[profile_id],
|
|
||||||
back_populates="mounts",
|
|
||||||
)
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Integer, JSON, String, Text, UniqueConstraint
|
|
||||||
from sqlalchemy import Uuid as UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from src.models.config_include import ConfigInclude
|
|
||||||
from src.models.config_mount import ConfigMount
|
|
||||||
from src.models.project import Project
|
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
from src.models.user import User
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|
||||||
__tablename__ = "config_profiles"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
|
||||||
)
|
|
||||||
|
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
project_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
|
|
||||||
)
|
|
||||||
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
|
|
||||||
)
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
environment_variables: Mapped[dict[str, str] | None] = mapped_column(
|
|
||||||
JSON, default=dict, nullable=True
|
|
||||||
)
|
|
||||||
start_command: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
working_directory: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
is_default: Mapped[bool] = mapped_column(default=False, nullable=False)
|
|
||||||
|
|
||||||
user: Mapped["User"] = relationship()
|
|
||||||
project: Mapped["Project | None"] = relationship()
|
|
||||||
tool_type: Mapped["ToolType | None"] = relationship()
|
|
||||||
includes: Mapped[list["ConfigInclude"]] = relationship(
|
|
||||||
"ConfigInclude",
|
|
||||||
primaryjoin="ConfigProfile.id == ConfigInclude.profile_id",
|
|
||||||
back_populates="profile",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
order_by="ConfigInclude.order_index",
|
|
||||||
)
|
|
||||||
mounts: Mapped[list["ConfigMount"]] = relationship(
|
|
||||||
"ConfigMount",
|
|
||||||
primaryjoin="ConfigProfile.id == ConfigMount.profile_id",
|
|
||||||
back_populates="profile",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
order_by="ConfigMount.order_index",
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Project models module."""
|
||||||
|
|
||||||
|
from src.models.project.git_repository import GitRepository
|
||||||
|
from src.models.project.project import Project
|
||||||
|
from src.models.project.workspace import Workspace
|
||||||
|
|
||||||
|
__all__ = ["GitRepository", "Project", "Workspace"]
|
||||||
+1
-1
@@ -10,7 +10,7 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models import SSHKey
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -8,8 +8,8 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.git_repository import GitRepository
|
from src.models import GitRepository
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models import SSHKey
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|||||||
from src.models.base import Base, TimestampMixin
|
from src.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.git_repository import GitRepository
|
from src.models import GitRepository
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""System models module."""
|
||||||
|
|
||||||
|
from src.models.system.health_check import HealthCheck
|
||||||
|
from src.models.system.instance_event import InstanceEvent
|
||||||
|
from src.models.system.notification import Notification
|
||||||
|
from src.models.system.terminal_session import TerminalSessionModel
|
||||||
|
|
||||||
|
__all__ = ["HealthCheck", "InstanceEvent", "Notification", "TerminalSessionModel"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Tool models module."""
|
||||||
|
|
||||||
|
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest
|
||||||
|
from src.models.tool.tool_instance import ToolInstance
|
||||||
|
from src.models.tool.tool_type import ToolType
|
||||||
|
|
||||||
|
__all__ = ["ToolDefinitionManifest", "ToolInstance", "ToolType"]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models import ConfigProfile
|
||||||
|
from src.models import GitRepository
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models import ToolType
|
||||||
|
from src.models.user import User
|
||||||
|
from src.models import Workspace
|
||||||
|
|
||||||
|
|
||||||
|
class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "tool_instances"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
tool_type_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("tool_types.id"), nullable=False
|
||||||
|
)
|
||||||
|
repository_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("git_repositories.id"), nullable=False
|
||||||
|
)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("projects.id"), nullable=False
|
||||||
|
)
|
||||||
|
owner_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("users.id"), nullable=False
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
|
||||||
|
container_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
container_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
compose_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
public_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
tunnel_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
last_started_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
manifest_compiled_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
image_tag: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
|
probe_result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
clone_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="mount")
|
||||||
|
branch: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), nullable=True, default="main"
|
||||||
|
)
|
||||||
|
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
ssh_key_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
workspace_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("workspaces.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_type: Mapped["ToolType"] = relationship()
|
||||||
|
workspace: Mapped["Workspace | None"] = relationship()
|
||||||
|
repository: Mapped["GitRepository"] = relationship()
|
||||||
|
project: Mapped["Project"] = relationship()
|
||||||
|
owner: Mapped["User"] = relationship()
|
||||||
|
selected_config_profile: Mapped["ConfigProfile | None"] = relationship()
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text, Uuid as UUID
|
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest
|
||||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
@@ -52,21 +52,3 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
foreign_keys=[manifest_id],
|
foreign_keys=[manifest_id],
|
||||||
)
|
)
|
||||||
created_by: Mapped["User | None"] = relationship()
|
created_by: Mapped["User | None"] = relationship()
|
||||||
|
|
||||||
@property
|
|
||||||
def interfaces(self) -> list[str]:
|
|
||||||
"""Backward-compatible API view for the single interface type."""
|
|
||||||
return [self.interface_type]
|
|
||||||
|
|
||||||
@interfaces.setter
|
|
||||||
def interfaces(self, value: list[str] | str) -> None:
|
|
||||||
"""Accept legacy interface lists and store the first interface type."""
|
|
||||||
if isinstance(value, str):
|
|
||||||
self.interface_type = value
|
|
||||||
return
|
|
||||||
self.interface_type = value[0] if value else "web"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_builtin(self) -> bool:
|
|
||||||
"""Built-in tools are seeded system tools without a creating user."""
|
|
||||||
return self.created_by_id is None
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
|
||||||
from sqlalchemy import Uuid as UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
from src.models.git_repository import GitRepository
|
|
||||||
from src.models.project import Project
|
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
from src.models.user import User
|
|
||||||
|
|
||||||
|
|
||||||
class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|
||||||
__tablename__ = "tool_instances"
|
|
||||||
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
tool_type_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("tool_types.id"), nullable=False
|
|
||||||
)
|
|
||||||
repository_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("git_repositories.id"), nullable=False
|
|
||||||
)
|
|
||||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("projects.id"), nullable=False
|
|
||||||
)
|
|
||||||
owner_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("users.id"), nullable=False
|
|
||||||
)
|
|
||||||
status: Mapped[str] = mapped_column(
|
|
||||||
String(50), nullable=False, default="pending"
|
|
||||||
)
|
|
||||||
container_id: Mapped[str | None] = mapped_column(
|
|
||||||
String(255), nullable=True
|
|
||||||
)
|
|
||||||
container_name: Mapped[str | None] = mapped_column(
|
|
||||||
String(255), nullable=True
|
|
||||||
)
|
|
||||||
compose_path: Mapped[str | None] = mapped_column(
|
|
||||||
String(1024), nullable=True
|
|
||||||
)
|
|
||||||
url: Mapped[str | None] = mapped_column(
|
|
||||||
String(1024), nullable=True
|
|
||||||
)
|
|
||||||
public_url: Mapped[str | None] = mapped_column(
|
|
||||||
String(1024), nullable=True
|
|
||||||
)
|
|
||||||
tunnel_id: Mapped[str | None] = mapped_column(
|
|
||||||
String(255), nullable=True
|
|
||||||
)
|
|
||||||
port: Mapped[int | None] = mapped_column(
|
|
||||||
Integer, nullable=True
|
|
||||||
)
|
|
||||||
last_started_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
selected_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
tool_type: Mapped["ToolType"] = relationship()
|
|
||||||
repository: Mapped["GitRepository"] = relationship()
|
|
||||||
project: Mapped["Project"] = relationship()
|
|
||||||
owner: Mapped["User"] = relationship()
|
|
||||||
selected_profile: Mapped["ConfigProfile | None"] = relationship()
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""User models module."""
|
||||||
|
|
||||||
|
from src.models.user.ssh_key import SSHKey
|
||||||
|
from src.models.user.user import User
|
||||||
|
from src.models.user.user_config import UserConfig
|
||||||
|
|
||||||
|
__all__ = ["SSHKey", "User", "UserConfig"]
|
||||||
@@ -7,8 +7,8 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models import SSHKey
|
||||||
from src.models.user_config import UserConfig
|
from src.models import UserConfig
|
||||||
|
|
||||||
|
|
||||||
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey
|
from sqlalchemy import ForeignKey, JSON
|
||||||
from sqlalchemy import JSON, Uuid as UUID
|
from sqlalchemy import Uuid as UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
@@ -14,15 +14,22 @@ if TYPE_CHECKING:
|
|||||||
class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
__tablename__ = "user_configs"
|
__tablename__ = "user_configs"
|
||||||
|
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False, unique=True)
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
|
UUID(), ForeignKey("users.id"), nullable=False, unique=True
|
||||||
|
)
|
||||||
|
config: Mapped[dict[str, object]] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship(back_populates="user_config")
|
user: Mapped["User"] = relationship(back_populates="user_config")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def default_profile_id(self) -> uuid.UUID | None:
|
def default_profile_id(self) -> uuid.UUID | None:
|
||||||
|
"""Return the legacy global default profile ID from config JSON."""
|
||||||
profile_id = self.config.get("default_profile_id")
|
profile_id = self.config.get("default_profile_id")
|
||||||
return uuid.UUID(profile_id) if profile_id else None
|
if isinstance(profile_id, str):
|
||||||
|
return uuid.UUID(profile_id)
|
||||||
|
return None
|
||||||
|
|
||||||
@default_profile_id.setter
|
@default_profile_id.setter
|
||||||
def default_profile_id(self, value: uuid.UUID | None) -> None:
|
def default_profile_id(self, value: uuid.UUID | None) -> None:
|
||||||
@@ -33,7 +40,11 @@ class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def default_profiles(self) -> dict[str, str]:
|
def default_profiles(self) -> dict[str, str]:
|
||||||
return self.config.get("default_profiles", {})
|
"""Return per-tool-type default profile IDs from config JSON."""
|
||||||
|
value = self.config.get("default_profiles", {})
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(k): str(v) for k, v in value.items()}
|
||||||
|
return {}
|
||||||
|
|
||||||
@default_profiles.setter
|
@default_profiles.setter
|
||||||
def default_profiles(self, value: dict[str, str]) -> None:
|
def default_profiles(self, value: dict[str, str]) -> None:
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Pydantic request/response schemas."""
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""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,131 +0,0 @@
|
|||||||
"""Config profile request/response schemas."""
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
|
||||||
|
|
||||||
MAX_MOUNT_PATH_LENGTH = 1024
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfileCreate(BaseModel):
|
|
||||||
name: str = Field(description="Profile name (unique per user)")
|
|
||||||
description: str | None = Field(default=None, description="Optional description")
|
|
||||||
|
|
||||||
@field_validator("name")
|
|
||||||
@classmethod
|
|
||||||
def validate_name(cls, v: str) -> str:
|
|
||||||
v = v.strip()
|
|
||||||
if not v:
|
|
||||||
raise ValueError("Profile name cannot be empty")
|
|
||||||
if len(v) > 255:
|
|
||||||
raise ValueError("Profile name must be 255 characters or less")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfileUpdate(BaseModel):
|
|
||||||
name: str | None = Field(default=None, description="Profile name")
|
|
||||||
description: str | None = Field(default=None, description="Optional description")
|
|
||||||
|
|
||||||
@field_validator("name")
|
|
||||||
@classmethod
|
|
||||||
def validate_name(cls, v: str | None) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
v = v.strip()
|
|
||||||
if not v:
|
|
||||||
raise ValueError("Profile name cannot be empty")
|
|
||||||
if len(v) > 255:
|
|
||||||
raise ValueError("Profile name must be 255 characters or less")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfileResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
user_id: str
|
|
||||||
name: str
|
|
||||||
description: str | None
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfileDetailResponse(ConfigProfileResponse):
|
|
||||||
includes: list[dict[str, Any]]
|
|
||||||
mounts: list[dict[str, Any]]
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigIncludeCreate(BaseModel):
|
|
||||||
included_profile_id: str = Field(description="UUID of the profile to include")
|
|
||||||
order_index: int = Field(default=0, description="Order index for include resolution")
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigIncludeUpdate(BaseModel):
|
|
||||||
order_index: int = Field(description="Order index for include resolution")
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigIncludeResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
profile_id: str
|
|
||||||
included_profile_id: str
|
|
||||||
included_profile_name: str | None
|
|
||||||
order_index: int
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigMountCreate(BaseModel):
|
|
||||||
target_path: str = Field(description="Absolute target path in container")
|
|
||||||
mode: str = Field(default="rw", description="Mount mode (rw or ro)")
|
|
||||||
files: dict[str, str] | None = Field(
|
|
||||||
default=None, description="Files as {path: content}"
|
|
||||||
)
|
|
||||||
order_index: int = Field(default=0, description="Order index for mount resolution")
|
|
||||||
|
|
||||||
@field_validator("target_path")
|
|
||||||
@classmethod
|
|
||||||
def validate_target_path(cls, v: str) -> str:
|
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("Target path must be absolute (start with /)")
|
|
||||||
if ".." in v:
|
|
||||||
raise ValueError("Target path cannot contain parent directory references (..)")
|
|
||||||
if len(v) > MAX_MOUNT_PATH_LENGTH:
|
|
||||||
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigMountUpdate(BaseModel):
|
|
||||||
target_path: str | None = Field(default=None, description="Absolute target path in container")
|
|
||||||
mode: str | None = Field(default=None, description="Mount mode (rw or ro)")
|
|
||||||
files: dict[str, str] | None = Field(
|
|
||||||
default=None, description="Files as {path: content}"
|
|
||||||
)
|
|
||||||
order_index: int | None = Field(default=None, description="Order index for mount resolution")
|
|
||||||
|
|
||||||
@field_validator("target_path")
|
|
||||||
@classmethod
|
|
||||||
def validate_target_path(cls, v: str | None) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("Target path must be absolute (start with /)")
|
|
||||||
if ".." in v:
|
|
||||||
raise ValueError("Target path cannot contain parent directory references (..)")
|
|
||||||
if len(v) > MAX_MOUNT_PATH_LENGTH:
|
|
||||||
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigMountResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
profile_id: str
|
|
||||||
target_path: str
|
|
||||||
mode: str
|
|
||||||
files: dict[str, str] | None
|
|
||||||
order_index: int
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class DefaultProfilesUpdate(BaseModel):
|
|
||||||
default_profiles: dict[str, str] = Field(
|
|
||||||
description="Mapping of tool_type_id to profile_id"
|
|
||||||
)
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
"""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
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
owner_id: uuid.UUID
|
|
||||||
is_mirror: bool
|
|
||||||
remote_url: str | None
|
|
||||||
last_push: datetime | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class FileListResponse(BaseModel):
|
|
||||||
path: str
|
|
||||||
branch: str
|
|
||||||
entries: list[dict]
|
|
||||||
|
|
||||||
|
|
||||||
class FileContentResponse(BaseModel):
|
|
||||||
path: str
|
|
||||||
branch: str
|
|
||||||
content: str
|
|
||||||
size: int
|
|
||||||
encoding: str
|
|
||||||
language: str | None
|
|
||||||
is_binary: bool
|
|
||||||
last_commit: dict | None
|
|
||||||
|
|
||||||
|
|
||||||
class BranchesResponse(BaseModel):
|
|
||||||
branches: list[dict]
|
|
||||||
default_branch: str
|
|
||||||
|
|
||||||
|
|
||||||
class FileUpdateRequest(BaseModel):
|
|
||||||
path: str
|
|
||||||
branch: str
|
|
||||||
content: str
|
|
||||||
commit_message: str
|
|
||||||
|
|
||||||
|
|
||||||
class FileUpdateResponse(BaseModel):
|
|
||||||
commit_hash: str
|
|
||||||
message: str
|
|
||||||
branch: str
|
|
||||||
|
|
||||||
|
|
||||||
class StatusResponse(BaseModel):
|
|
||||||
branch: str
|
|
||||||
modified: list[str]
|
|
||||||
added: list[str]
|
|
||||||
deleted: list[str]
|
|
||||||
untracked: list[str]
|
|
||||||
renamed: list[str]
|
|
||||||
ahead: int
|
|
||||||
behind: int
|
|
||||||
|
|
||||||
|
|
||||||
class BranchCreateRequest(BaseModel):
|
|
||||||
name: str
|
|
||||||
base_branch: str = "HEAD"
|
|
||||||
|
|
||||||
|
|
||||||
class CheckoutRequest(BaseModel):
|
|
||||||
branch: str
|
|
||||||
|
|
||||||
|
|
||||||
class CommitRequest(BaseModel):
|
|
||||||
message: str
|
|
||||||
files: list[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CommitResponse(BaseModel):
|
|
||||||
commit_hash: str
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class FetchResponse(BaseModel):
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class PullResponse(BaseModel):
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class PushResponse(BaseModel):
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class MergeRequest(BaseModel):
|
|
||||||
source_branch: str
|
|
||||||
target_branch: str | None = None
|
|
||||||
message: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class MergeResponse(BaseModel):
|
|
||||||
commit_hash: str
|
|
||||||
message: str
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""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
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Project request/response schemas."""
|
"""Project request/response schemas."""
|
||||||
|
|
||||||
from pydantic import BaseModel
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
class ProjectCreate(BaseModel):
|
class ProjectCreate(BaseModel):
|
||||||
@@ -14,12 +16,14 @@ class ProjectUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ProjectResponse(BaseModel):
|
class ProjectResponse(BaseModel):
|
||||||
id: str
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
name: str
|
name: str
|
||||||
description: str | None
|
description: str | None
|
||||||
created_at: str
|
owner_id: uuid.UUID
|
||||||
updated_at: str
|
default_ssh_key_id: uuid.UUID | None
|
||||||
|
|
||||||
|
|
||||||
class SetDefaultSSHKeyRequest(BaseModel):
|
class SetDefaultSSHKeyRequest(BaseModel):
|
||||||
ssh_key_id: str
|
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,16 +0,0 @@
|
|||||||
"""SSH key request/response schemas."""
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class SSHKeyCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
public_key: str
|
|
||||||
|
|
||||||
|
|
||||||
class SSHKeyResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
public_key: str
|
|
||||||
fingerprint: str
|
|
||||||
created_at: str
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""System schemas module."""
|
||||||
|
|
||||||
|
from src.schemas.system.health import (
|
||||||
|
DatabaseHealth,
|
||||||
|
DatabaseHealthResponse,
|
||||||
|
DiskHealth,
|
||||||
|
HealthChecks,
|
||||||
|
HealthResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DatabaseHealth",
|
||||||
|
"DatabaseHealthResponse",
|
||||||
|
"DiskHealth",
|
||||||
|
"HealthChecks",
|
||||||
|
"HealthResponse",
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""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"
|
||||||
|
)
|
||||||
@@ -1,31 +1,42 @@
|
|||||||
"""Tool type request/response schemas."""
|
"""Tool type request/response schemas."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import yaml
|
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
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):
|
class ToolTypeCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
display_name: str
|
display_name: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
default_port: int
|
default_port: int = 0
|
||||||
definition_type: str = "compose"
|
definition_type: str = "compose"
|
||||||
|
manifest_id: uuid.UUID | None = None
|
||||||
compose_template: str | None = None
|
compose_template: str | None = None
|
||||||
dockerfile_template: str | None = None
|
dockerfile_template: str | None = None
|
||||||
build_context: dict | None = None
|
build_context: dict | None = None
|
||||||
readiness_probe: dict | None = None
|
readiness_probe: dict | None = None
|
||||||
|
startup_command: str | None = None
|
||||||
required_variables: list[str] = []
|
required_variables: list[str] = []
|
||||||
category: str = "other"
|
category: str = "other"
|
||||||
interfaces: list[str] = ["web"]
|
interface_type: str = "web"
|
||||||
|
requires_port: bool = True
|
||||||
|
|
||||||
@field_validator("definition_type")
|
@field_validator("definition_type")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_definition_type(cls, v: str) -> str:
|
def validate_definition_type(cls, v: str) -> str:
|
||||||
if v not in ("compose", "dockerfile"):
|
if v not in ("compose", "dockerfile", "manifest"):
|
||||||
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
raise ValueError(
|
||||||
|
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
|
||||||
|
)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("compose_template")
|
@field_validator("compose_template")
|
||||||
@@ -34,18 +45,13 @@ class ToolTypeCreate(BaseModel):
|
|||||||
data = info.data
|
data = info.data
|
||||||
if data.get("definition_type") != "compose":
|
if data.get("definition_type") != "compose":
|
||||||
return v
|
return v
|
||||||
if v is None:
|
|
||||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
if v is None or not v.strip():
|
||||||
try:
|
raise ValueError(
|
||||||
parsed = yaml.safe_load(v)
|
"compose_template is required when definition_type is 'compose'"
|
||||||
except yaml.YAMLError as e:
|
)
|
||||||
raise ValueError(f"Invalid YAML: {e}")
|
|
||||||
if not isinstance(parsed, dict):
|
validate_compose_yaml(v)
|
||||||
raise ValueError("Compose template must be a YAML mapping")
|
|
||||||
if "services" not in parsed:
|
|
||||||
raise ValueError("Compose template must contain 'services' key")
|
|
||||||
if not parsed["services"]:
|
|
||||||
raise ValueError("Compose template must define at least one service")
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("dockerfile_template")
|
@field_validator("dockerfile_template")
|
||||||
@@ -54,43 +60,33 @@ class ToolTypeCreate(BaseModel):
|
|||||||
data = info.data
|
data = info.data
|
||||||
if data.get("definition_type") != "dockerfile":
|
if data.get("definition_type") != "dockerfile":
|
||||||
return v
|
return v
|
||||||
if v is None:
|
|
||||||
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
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"):
|
if not v.strip().startswith("FROM"):
|
||||||
raise ValueError("Dockerfile must start with a FROM instruction")
|
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
|
return v
|
||||||
|
|
||||||
@field_validator("default_port")
|
@field_validator("default_port")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_default_port(cls, v: int, info) -> int:
|
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:
|
if v <= 0 or v > 65535:
|
||||||
raise ValueError("Port must be between 1 and 65535")
|
raise ValueError("Port must be between 1 and 65535")
|
||||||
data = info.data
|
|
||||||
if data.get("definition_type") != "compose":
|
|
||||||
return v
|
|
||||||
template = data.get("compose_template")
|
|
||||||
if not template:
|
|
||||||
return v
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(template)
|
|
||||||
except yaml.YAMLError:
|
|
||||||
return v
|
|
||||||
port_str = str(v)
|
|
||||||
port_exposed = False
|
|
||||||
if isinstance(parsed, dict) and "services" in parsed:
|
|
||||||
for service_config in parsed["services"].values():
|
|
||||||
if isinstance(service_config, dict) and "ports" in service_config:
|
|
||||||
for port_mapping in service_config["ports"]:
|
|
||||||
if isinstance(port_mapping, str) and port_str in port_mapping:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
elif isinstance(port_mapping, int) and port_mapping == v:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
if port_exposed:
|
|
||||||
break
|
|
||||||
if not port_exposed:
|
|
||||||
raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.")
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("required_variables")
|
@field_validator("required_variables")
|
||||||
@@ -98,24 +94,55 @@ class ToolTypeCreate(BaseModel):
|
|||||||
def validate_required_variables(cls, v: list[str], info) -> list[str]:
|
def validate_required_variables(cls, v: list[str], info) -> list[str]:
|
||||||
if not v:
|
if not v:
|
||||||
return v
|
return v
|
||||||
|
|
||||||
data = info.data
|
data = info.data
|
||||||
if data.get("definition_type") != "compose":
|
if data.get("definition_type") != "compose":
|
||||||
return v
|
return v
|
||||||
|
|
||||||
template = data.get("compose_template")
|
template = data.get("compose_template")
|
||||||
if not template:
|
if not template:
|
||||||
return v
|
return v
|
||||||
for var in v:
|
|
||||||
placeholder = f"{{{{{var}}}}}"
|
validate_required_variables(template, v)
|
||||||
if placeholder not in template:
|
|
||||||
raise ValueError(f"Required variable '{var}' not found in compose template")
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_templates(self) -> "ToolTypeCreate":
|
def validate_templates(self) -> "ToolTypeCreate":
|
||||||
if self.definition_type == "dockerfile" and self.dockerfile_template is None:
|
if self.definition_type == "manifest":
|
||||||
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
if self.manifest_id is None:
|
||||||
if self.definition_type == "compose" and self.compose_template is None:
|
raise ValueError(
|
||||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
"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
|
return self
|
||||||
|
|
||||||
|
|
||||||
@@ -124,21 +151,35 @@ class ToolTypeUpdate(BaseModel):
|
|||||||
description: str | None = None
|
description: str | None = None
|
||||||
default_port: int | None = None
|
default_port: int | None = None
|
||||||
definition_type: str | None = None
|
definition_type: str | None = None
|
||||||
|
manifest_id: uuid.UUID | None = None
|
||||||
compose_template: str | None = None
|
compose_template: str | None = None
|
||||||
dockerfile_template: str | None = None
|
dockerfile_template: str | None = None
|
||||||
build_context: dict | None = None
|
build_context: dict | None = None
|
||||||
readiness_probe: dict | None = None
|
readiness_probe: dict | None = None
|
||||||
|
startup_command: str | None = None
|
||||||
required_variables: list[str] | None = None
|
required_variables: list[str] | None = None
|
||||||
category: str | None = None
|
category: str | None = None
|
||||||
interfaces: list[str] | None = None
|
interface_type: str | None = None
|
||||||
|
requires_port: bool | None = None
|
||||||
|
|
||||||
@field_validator("definition_type")
|
@field_validator("definition_type")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_definition_type(cls, v: str | None) -> str | None:
|
def validate_definition_type(cls, v: str | None) -> str | None:
|
||||||
if v is None:
|
if v is None:
|
||||||
return v
|
return v
|
||||||
if v not in ("compose", "dockerfile"):
|
if v not in ("compose", "dockerfile", "manifest"):
|
||||||
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
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
|
return v
|
||||||
|
|
||||||
@field_validator("compose_template")
|
@field_validator("compose_template")
|
||||||
@@ -146,20 +187,7 @@ class ToolTypeUpdate(BaseModel):
|
|||||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||||
if v is None:
|
if v is None:
|
||||||
return v
|
return v
|
||||||
data = info.data
|
validate_compose_yaml(v)
|
||||||
definition_type = data.get("definition_type")
|
|
||||||
if definition_type and definition_type != "compose":
|
|
||||||
return v
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(v)
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
raise ValueError(f"Invalid YAML: {e}")
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
raise ValueError("Compose template must be a YAML mapping")
|
|
||||||
if "services" not in parsed:
|
|
||||||
raise ValueError("Compose template must contain 'services' key")
|
|
||||||
if not parsed["services"]:
|
|
||||||
raise ValueError("Compose template must define at least one service")
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("dockerfile_template")
|
@field_validator("dockerfile_template")
|
||||||
@@ -167,10 +195,6 @@ class ToolTypeUpdate(BaseModel):
|
|||||||
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||||
if v is None:
|
if v is None:
|
||||||
return v
|
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"):
|
if not v.strip().startswith("FROM"):
|
||||||
raise ValueError("Dockerfile must start with a FROM instruction")
|
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||||
return v
|
return v
|
||||||
@@ -184,15 +208,17 @@ class ToolTypeResponse(BaseModel):
|
|||||||
display_name: str
|
display_name: str
|
||||||
description: str | None
|
description: str | None
|
||||||
category: str
|
category: str
|
||||||
interfaces: list[str]
|
interface_type: str
|
||||||
|
requires_port: bool
|
||||||
default_port: int
|
default_port: int
|
||||||
definition_type: str
|
definition_type: str
|
||||||
|
manifest_id: uuid.UUID | None
|
||||||
compose_template: str | None
|
compose_template: str | None
|
||||||
dockerfile_template: str | None
|
dockerfile_template: str | None
|
||||||
build_context: dict | None
|
build_context: dict | None
|
||||||
readiness_probe: dict | None
|
readiness_probe: dict | None
|
||||||
|
startup_command: str | None
|
||||||
required_variables: list[str]
|
required_variables: list[str]
|
||||||
is_builtin: bool
|
|
||||||
created_by_id: uuid.UUID | None
|
created_by_id: uuid.UUID | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"""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"
|
|
||||||
)
|
|
||||||
config_profile_id: str | None = Field(
|
|
||||||
default=None, description="Optional config profile ID to apply to the instance"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SessionItemResponse(BaseModel):
|
|
||||||
"""Lightweight session summary for sidebar and dashboard."""
|
|
||||||
|
|
||||||
model_config = {"extra": "ignore"}
|
|
||||||
|
|
||||||
id: str = Field(description="Session (tool instance) ID")
|
|
||||||
display_name: str = Field(description="Display name of the session")
|
|
||||||
tool_type_name: str = Field(description="Name of the tool type")
|
|
||||||
tool_icon: str | None = Field(default=None, description="Icon URL for the tool type")
|
|
||||||
tool_type_interfaces: list[str] = Field(default_factory=list, description="Supported interfaces")
|
|
||||||
repository_name: str = Field(description="Name of the repository")
|
|
||||||
repository_id: str = Field(description="Repository ID")
|
|
||||||
project_name: str = Field(description="Name of the project")
|
|
||||||
project_id: str = Field(description="Project ID")
|
|
||||||
status: str = Field(description="Current status")
|
|
||||||
url: str | None = Field(default=None, description="Access URL")
|
|
||||||
|
|
||||||
|
|
||||||
class SessionListResponse(BaseModel):
|
|
||||||
"""Response wrapping a list of session summaries."""
|
|
||||||
|
|
||||||
sessions: list[SessionItemResponse]
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""User request/response schemas."""
|
"""User response schemas."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""User config request/response schemas."""
|
"""User config response schemas."""
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
@@ -11,6 +11,8 @@ class UserConfigResponse(BaseModel):
|
|||||||
git_user_name: str | None = None
|
git_user_name: str | None = None
|
||||||
git_user_email: str | None = None
|
git_user_email: str | None = None
|
||||||
last_session_id: 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):
|
class UserConfigUpdate(BaseModel):
|
||||||
@@ -19,3 +21,5 @@ class UserConfigUpdate(BaseModel):
|
|||||||
git_user_name: str | None = None
|
git_user_name: str | None = None
|
||||||
git_user_email: str | None = None
|
git_user_email: str | None = None
|
||||||
last_session_id: str | None = None
|
last_session_id: str | None = None
|
||||||
|
notification_mute_categories: list[str] | None = None
|
||||||
|
notification_toast_level: str | None = None
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Database seeding utilities."""
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
|
"""Seed built-in tool types into the database."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
from src.models.tool_type import ToolType
|
from src.models import ToolType
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def _table_exists(session, table_name: str) -> bool:
|
async def _table_exists(session, table_name: str) -> bool:
|
||||||
"""Check if a table exists in the database."""
|
"""Check if a table exists in the database."""
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
text(
|
text(
|
||||||
@@ -31,6 +31,11 @@ async def _table_exists(session, table_name: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def seed_builtin_tool_types():
|
async def seed_builtin_tool_types():
|
||||||
|
"""Create or update built-in tool types in the database.
|
||||||
|
|
||||||
|
Built-in tool types have no creator (created_by_id=None) and provide
|
||||||
|
out-of-the-box tools for users without requiring manual tool creation.
|
||||||
|
"""
|
||||||
async with SessionLocal() as session:
|
async with SessionLocal() as session:
|
||||||
# Check if tool_types table exists before attempting to seed
|
# Check if tool_types table exists before attempting to seed
|
||||||
if not await _table_exists(session, "tool_types"):
|
if not await _table_exists(session, "tool_types"):
|
||||||
@@ -142,7 +147,8 @@ volumes:
|
|||||||
definition_type="compose",
|
definition_type="compose",
|
||||||
compose_template=tool_data["compose_template"],
|
compose_template=tool_data["compose_template"],
|
||||||
required_variables=tool_data["required_variables"],
|
required_variables=tool_data["required_variables"],
|
||||||
default_port=tool_data["default_port"],
|
default_port=tool_data.get("default_port", 0),
|
||||||
|
created_by_id=None,
|
||||||
)
|
)
|
||||||
session.add(tool_type)
|
session.add(tool_type)
|
||||||
logger.info("Created built-in tool type: %s", tool_data["name"])
|
logger.info("Created built-in tool type: %s", tool_data["name"])
|
||||||
@@ -155,7 +161,7 @@ volumes:
|
|||||||
existing.definition_type = "compose"
|
existing.definition_type = "compose"
|
||||||
existing.compose_template = tool_data["compose_template"]
|
existing.compose_template = tool_data["compose_template"]
|
||||||
existing.required_variables = tool_data["required_variables"]
|
existing.required_variables = tool_data["required_variables"]
|
||||||
existing.default_port = tool_data["default_port"]
|
existing.default_port = tool_data.get("default_port", 0)
|
||||||
logger.info("Updated built-in tool type: %s", tool_data["name"])
|
logger.info("Updated built-in tool type: %s", tool_data["name"])
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
+1
-1
@@ -13,7 +13,7 @@ from typing import Any
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
from src.models import ConfigProfile, ConfigProfileInclude
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
"""Config profile business logic."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from src.models.config_include import ConfigInclude
|
|
||||||
from src.models.config_mount import ConfigMount
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
from src.models.user_config import UserConfig
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
MAX_INCLUDES_DEPTH = 10
|
|
||||||
|
|
||||||
|
|
||||||
async def get_owned_profile(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> ConfigProfile:
|
|
||||||
"""Fetch a config profile and verify ownership."""
|
|
||||||
profile = await session.get(ConfigProfile, profile_id)
|
|
||||||
if profile is None or profile.user_id != user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="config profile not found",
|
|
||||||
)
|
|
||||||
return profile
|
|
||||||
|
|
||||||
|
|
||||||
async def _detect_cycle(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
visited: set[uuid.UUID] | None = None,
|
|
||||||
depth: int = 0,
|
|
||||||
) -> bool:
|
|
||||||
"""Detect cycles in profile includes using DFS.
|
|
||||||
|
|
||||||
Returns True if a cycle is detected.
|
|
||||||
"""
|
|
||||||
if depth > MAX_INCLUDES_DEPTH:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if visited is None:
|
|
||||||
visited = set()
|
|
||||||
|
|
||||||
if profile_id in visited:
|
|
||||||
return True
|
|
||||||
|
|
||||||
visited.add(profile_id)
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConfigInclude.included_profile_id).where(
|
|
||||||
ConfigInclude.profile_id == profile_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
included_ids = result.scalars().all()
|
|
||||||
|
|
||||||
for included_id in included_ids:
|
|
||||||
if await _detect_cycle(session, included_id, visited.copy(), depth + 1):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_includes_no_cycle(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
new_included_id: uuid.UUID | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Validate that adding an include wouldn't create a cycle."""
|
|
||||||
if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="adding this include would create a circular reference",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Profile CRUD helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def check_duplicate_name(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
name: str,
|
|
||||||
exclude_id: uuid.UUID | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Raise 409 if a profile with the given name already exists."""
|
|
||||||
query = select(ConfigProfile).where(
|
|
||||||
ConfigProfile.user_id == user_id,
|
|
||||||
ConfigProfile.name == name,
|
|
||||||
)
|
|
||||||
if exclude_id:
|
|
||||||
query = query.where(ConfigProfile.id != exclude_id)
|
|
||||||
existing = await session.scalar(query)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"config profile with name '{name}' already exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def profile_to_dict(profile: ConfigProfile) -> dict:
|
|
||||||
"""Serialize a ConfigProfile to a dict."""
|
|
||||||
return {
|
|
||||||
"id": str(profile.id),
|
|
||||||
"user_id": str(profile.user_id),
|
|
||||||
"name": profile.name,
|
|
||||||
"description": profile.description,
|
|
||||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
|
||||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Include helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def check_duplicate_include(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
included_profile_id: uuid.UUID,
|
|
||||||
) -> None:
|
|
||||||
"""Raise 409 if the include already exists."""
|
|
||||||
existing = await session.scalar(
|
|
||||||
select(ConfigInclude).where(
|
|
||||||
ConfigInclude.profile_id == profile_id,
|
|
||||||
ConfigInclude.included_profile_id == included_profile_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="this include already exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def include_to_dict(inc: ConfigInclude, included_name: str | None) -> dict:
|
|
||||||
"""Serialize a ConfigInclude to a dict."""
|
|
||||||
return {
|
|
||||||
"id": str(inc.id),
|
|
||||||
"profile_id": str(inc.profile_id),
|
|
||||||
"included_profile_id": str(inc.included_profile_id),
|
|
||||||
"included_profile_name": included_name,
|
|
||||||
"order_index": inc.order_index,
|
|
||||||
"created_at": inc.created_at.isoformat() if inc.created_at else None,
|
|
||||||
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Mount helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def check_duplicate_mount_path(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
target_path: str,
|
|
||||||
exclude_id: uuid.UUID | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Raise 409 if a mount with the given path already exists."""
|
|
||||||
query = select(ConfigMount).where(
|
|
||||||
ConfigMount.profile_id == profile_id,
|
|
||||||
ConfigMount.target_path == target_path,
|
|
||||||
)
|
|
||||||
if exclude_id:
|
|
||||||
query = query.where(ConfigMount.id != exclude_id)
|
|
||||||
existing = await session.scalar(query)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"mount with path '{target_path}' already exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def mount_to_dict(mount: ConfigMount) -> dict:
|
|
||||||
"""Serialize a ConfigMount to a dict."""
|
|
||||||
return {
|
|
||||||
"id": str(mount.id),
|
|
||||||
"profile_id": str(mount.profile_id),
|
|
||||||
"target_path": mount.target_path,
|
|
||||||
"files": mount.files,
|
|
||||||
"mode": mount.mode,
|
|
||||||
"order_index": mount.order_index,
|
|
||||||
"created_at": mount.created_at.isoformat() if mount.created_at else None,
|
|
||||||
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Default profile helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def get_or_create_user_config(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
) -> UserConfig:
|
|
||||||
"""Get existing user config or create a new one."""
|
|
||||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
|
||||||
user_config = result.scalar_one_or_none()
|
|
||||||
if user_config is None:
|
|
||||||
user_config = UserConfig(user_id=user_id, config={})
|
|
||||||
session.add(user_config)
|
|
||||||
return user_config
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_default_profiles(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
default_profiles: dict[str, str],
|
|
||||||
) -> None:
|
|
||||||
"""Validate that all profile IDs in default_profiles belong to the user."""
|
|
||||||
for tool_type_id, profile_id_str in default_profiles.items():
|
|
||||||
profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str))
|
|
||||||
if profile is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"profile {profile_id_str} not found")
|
|
||||||
if profile.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"profile {profile_id_str} does not belong to user")
|
|
||||||
|
|
||||||
|
|
||||||
async def get_default_profiles(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
) -> dict:
|
|
||||||
"""Get default profiles for a user."""
|
|
||||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
|
||||||
user_config = result.scalar_one_or_none()
|
|
||||||
return {"default_profiles": user_config.default_profiles if user_config else {}}
|
|
||||||
|
|
||||||
|
|
||||||
async def set_default_profiles(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
default_profiles: dict[str, str],
|
|
||||||
) -> dict:
|
|
||||||
"""Set default profiles for a user."""
|
|
||||||
user_config = await get_or_create_user_config(session, user_id)
|
|
||||||
await validate_default_profiles(session, user_id, default_profiles)
|
|
||||||
user_config.config = {**user_config.config, "default_profiles": default_profiles}
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(user_config)
|
|
||||||
return {"default_profiles": user_config.default_profiles}
|
|
||||||
|
|
||||||
|
|
||||||
async def get_default_profile_for_tool_type(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
tool_type_id: str,
|
|
||||||
) -> dict:
|
|
||||||
"""Get default profile for a specific tool type."""
|
|
||||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
|
||||||
user_config = result.scalar_one_or_none()
|
|
||||||
profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None
|
|
||||||
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Include list helper
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def list_includes_for_profile(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
) -> dict:
|
|
||||||
"""List all includes for a profile."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConfigInclude)
|
|
||||||
.where(ConfigInclude.profile_id == profile_id)
|
|
||||||
.order_by(ConfigInclude.order_index)
|
|
||||||
)
|
|
||||||
includes_data = []
|
|
||||||
for inc in result.scalars().all():
|
|
||||||
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
|
|
||||||
includes_data.append(include_to_dict(inc, included_profile.name if included_profile else None))
|
|
||||||
return {"includes": includes_data}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Mount list helper
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def list_mounts_for_profile(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
) -> dict:
|
|
||||||
"""List all mounts for a profile."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConfigMount)
|
|
||||||
.where(ConfigMount.profile_id == profile_id)
|
|
||||||
.order_by(ConfigMount.order_index)
|
|
||||||
)
|
|
||||||
return {"mounts": [mount_to_dict(m) for m in result.scalars().all()]}
|
|
||||||
@@ -1,43 +1,55 @@
|
|||||||
"""Docker services for container and tunnel management."""
|
"""Docker services package for container and compose operations."""
|
||||||
|
|
||||||
from .compose import (
|
from src.services.docker.compose import (
|
||||||
ensure_instance_directory,
|
|
||||||
execute_compose_command,
|
execute_compose_command,
|
||||||
render_compose_template,
|
render_compose_template,
|
||||||
|
sort_volumes_by_specificity,
|
||||||
write_compose_file,
|
write_compose_file,
|
||||||
|
)
|
||||||
|
from src.services.docker.config_staging import (
|
||||||
|
ensure_instance_directory,
|
||||||
|
write_config_files,
|
||||||
write_env_file,
|
write_env_file,
|
||||||
)
|
)
|
||||||
from .config_staging import write_config_files
|
from src.services.docker.container import (
|
||||||
from .container import (
|
|
||||||
connect_container_to_network,
|
connect_container_to_network,
|
||||||
find_free_port,
|
find_free_port,
|
||||||
|
get_backend_network_name,
|
||||||
get_container_id,
|
get_container_id,
|
||||||
|
get_container_ip_on_network,
|
||||||
get_container_logs,
|
get_container_logs,
|
||||||
get_container_name,
|
get_container_name,
|
||||||
get_container_status,
|
get_container_status,
|
||||||
|
is_container_on_network,
|
||||||
|
wait_for_container_running,
|
||||||
)
|
)
|
||||||
from .tunnel import (
|
from src.services.docker.tunnel import (
|
||||||
check_tunnel_health,
|
check_tunnel_health,
|
||||||
recreate_tunnel,
|
recreate_tunnel,
|
||||||
start_cloudflared_tunnel,
|
start_tunnel,
|
||||||
stop_cloudflared_tunnel,
|
stop_tunnel,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"render_compose_template",
|
|
||||||
"ensure_instance_directory",
|
|
||||||
"write_compose_file",
|
|
||||||
"write_env_file",
|
|
||||||
"execute_compose_command",
|
|
||||||
"write_config_files",
|
|
||||||
"get_container_id",
|
|
||||||
"get_container_name",
|
|
||||||
"connect_container_to_network",
|
|
||||||
"get_container_status",
|
|
||||||
"get_container_logs",
|
|
||||||
"find_free_port",
|
|
||||||
"start_cloudflared_tunnel",
|
|
||||||
"stop_cloudflared_tunnel",
|
|
||||||
"recreate_tunnel",
|
|
||||||
"check_tunnel_health",
|
"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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,133 +1,50 @@
|
|||||||
"""Docker Compose file generation and command execution."""
|
"""Docker Compose file generation and manipulation."""
|
||||||
|
|
||||||
import re
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
import uuid
|
from collections import Counter
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
logger = logging.getLogger(__name__)
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
from src.models.tool_instance import ToolInstance
|
|
||||||
from src.services.profile_resolver import resolve_profile
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_name(name: str) -> str:
|
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
|
||||||
"""Sanitize a string for use in Docker/container names."""
|
"""Sort volume strings so parent paths come before child paths.
|
||||||
sanitized = re.sub(r"[^a-z0-9-]", "-", name.lower())
|
|
||||||
sanitized = re.sub(r"-+", "-", sanitized)
|
|
||||||
return sanitized.strip("-")
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
async def _generate_instance_name(
|
Volume format: source:target or source:target:type
|
||||||
session: AsyncSession,
|
|
||||||
project_name: str,
|
|
||||||
tool_type_name: str,
|
|
||||||
) -> str:
|
|
||||||
"""Generate a unique instance name: project-tool-NUM."""
|
|
||||||
base = f"{_sanitize_name(project_name)}-{_sanitize_name(tool_type_name)}"
|
|
||||||
base = base.strip("-") or "instance"
|
|
||||||
result = await session.execute(
|
|
||||||
select(ToolInstance.name).where(ToolInstance.name.like(f"{base}-%"))
|
|
||||||
)
|
|
||||||
names = result.scalars().all()
|
|
||||||
max_num = 0
|
|
||||||
for name in names:
|
|
||||||
parts = name.rsplit("-", 1)
|
|
||||||
if len(parts) == 2 and parts[0] == base and parts[1].isdigit():
|
|
||||||
max_num = max(max_num, int(parts[1]))
|
|
||||||
return f"{base}-{max_num + 1:03d}"
|
|
||||||
|
|
||||||
|
Args:
|
||||||
|
volumes: List of Docker volume mount strings.
|
||||||
|
|
||||||
def _modify_compose_file(
|
Returns:
|
||||||
compose_path: str,
|
Sorted list with parent paths before child paths.
|
||||||
port_override: int | None = None,
|
"""
|
||||||
start_command: str | None = None,
|
|
||||||
working_directory: str | None = None,
|
|
||||||
extra_volumes: list[dict] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Modify compose file with runtime overrides."""
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
compose_file = Path(compose_path)
|
def _target_depth(vol: str) -> int:
|
||||||
content = compose_file.read_text()
|
parts = vol.split(":")
|
||||||
compose_data = yaml.safe_load(content)
|
if len(parts) < 2:
|
||||||
|
return 0
|
||||||
|
target = parts[1].rstrip("/")
|
||||||
|
if not target or target == "/":
|
||||||
|
return 0
|
||||||
|
return target.count("/")
|
||||||
|
|
||||||
if not compose_data or "services" not in compose_data:
|
# Detect duplicate targets and warn
|
||||||
return
|
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)
|
||||||
|
|
||||||
for service_name, service_config in compose_data["services"].items():
|
# Stable sort: parent paths first, child paths last
|
||||||
if port_override and "ports" in service_config:
|
return sorted(volumes, key=_target_depth)
|
||||||
for i, port_mapping in enumerate(service_config["ports"]):
|
|
||||||
if isinstance(port_mapping, str) and ":" in port_mapping:
|
|
||||||
_host_port, container_port = port_mapping.split(":", 1)
|
|
||||||
service_config["ports"][i] = f"{port_override}:{container_port}"
|
|
||||||
break
|
|
||||||
|
|
||||||
if start_command:
|
|
||||||
service_config["command"] = start_command
|
|
||||||
|
|
||||||
if working_directory:
|
|
||||||
service_config["working_dir"] = working_directory
|
|
||||||
|
|
||||||
if extra_volumes:
|
|
||||||
if "volumes" not in service_config:
|
|
||||||
service_config["volumes"] = []
|
|
||||||
for vol in extra_volumes:
|
|
||||||
source = vol.get("source", "")
|
|
||||||
target = vol.get("target", "")
|
|
||||||
vol_type = vol.get("type", "bind")
|
|
||||||
if vol_type == "bind":
|
|
||||||
service_config["volumes"].append(f"{source}:{target}")
|
|
||||||
else:
|
|
||||||
service_config["volumes"].append(f"{source}:{target}:{vol_type}")
|
|
||||||
|
|
||||||
break
|
|
||||||
|
|
||||||
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
|
||||||
|
|
||||||
|
|
||||||
async def _apply_resolved_profile(
|
|
||||||
profile: ConfigProfile,
|
|
||||||
instance_dir: str,
|
|
||||||
env_vars: dict[str, str],
|
|
||||||
port_override: int | None,
|
|
||||||
start_command: str | None,
|
|
||||||
working_directory: str | None,
|
|
||||||
extra_volumes: list[dict],
|
|
||||||
) -> tuple[dict[str, str], int | None, str | None, str | None, list[dict]]:
|
|
||||||
"""Resolve a profile and apply its output to instance configuration."""
|
|
||||||
resolved = resolve_profile(profile)
|
|
||||||
|
|
||||||
if resolved.environment_variables:
|
|
||||||
env_vars.update(resolved.environment_variables)
|
|
||||||
|
|
||||||
if resolved.runtime_hints.start_command is not None:
|
|
||||||
start_command = resolved.runtime_hints.start_command
|
|
||||||
if resolved.runtime_hints.working_directory is not None:
|
|
||||||
working_directory = resolved.runtime_hints.working_directory
|
|
||||||
if resolved.runtime_hints.port is not None:
|
|
||||||
port_override = resolved.runtime_hints.port
|
|
||||||
|
|
||||||
for target_path, mount in resolved.mounts.items():
|
|
||||||
safe_name = target_path.strip("/").replace("/", "_")
|
|
||||||
mount_dir = Path(instance_dir) / "mounts" / safe_name
|
|
||||||
mount_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for rel_path, content in mount.files.items():
|
|
||||||
file_path = mount_dir / rel_path
|
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
file_path.write_text(content)
|
|
||||||
|
|
||||||
extra_volumes.append({
|
|
||||||
"source": str(mount_dir),
|
|
||||||
"target": target_path,
|
|
||||||
"type": mount.mode,
|
|
||||||
})
|
|
||||||
|
|
||||||
return env_vars, port_override, start_command, working_directory, extra_volumes
|
|
||||||
|
|
||||||
|
|
||||||
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
||||||
@@ -147,24 +64,6 @@ def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
|||||||
return result
|
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:
|
def write_compose_file(instance_dir: str, content: str) -> str:
|
||||||
"""Write the rendered compose file to the instance directory.
|
"""Write the rendered compose file to the instance directory.
|
||||||
|
|
||||||
@@ -180,22 +79,6 @@ def write_compose_file(instance_dir: str, content: str) -> str:
|
|||||||
return str(compose_path)
|
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 execute_compose_command(
|
def execute_compose_command(
|
||||||
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||||
) -> tuple[int, str, str]:
|
) -> tuple[int, str, str]:
|
||||||
@@ -218,7 +101,7 @@ def execute_compose_command(
|
|||||||
cmd.extend(["--env-file", env_file])
|
cmd.extend(["--env-file", env_file])
|
||||||
|
|
||||||
if action == "up":
|
if action == "up":
|
||||||
cmd.extend(["up", "-d"])
|
cmd.extend(["up", "-d", "--force-recreate"])
|
||||||
elif action == "down":
|
elif action == "down":
|
||||||
cmd.extend(["down", "-v"])
|
cmd.extend(["down", "-v"])
|
||||||
elif action in ("start", "stop", "restart"):
|
elif action in ("start", "stop", "restart"):
|
||||||
|
|||||||
@@ -1,7 +1,46 @@
|
|||||||
"""Config file staging for Docker instances."""
|
"""Staging configuration files into instance directories."""
|
||||||
|
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
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:
|
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||||
"""Write config files to the instance directory.
|
"""Write config files to the instance directory.
|
||||||
|
|
||||||
|
|||||||
@@ -1,59 +1,123 @@
|
|||||||
"""Docker container lifecycle and query operations."""
|
"""Docker container runtime queries and network management."""
|
||||||
|
|
||||||
import socket
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
Uses exact name matching to avoid substring collisions with tunnel
|
||||||
|
containers (e.g. tunnel-code-server-... matching code-server-...).
|
||||||
|
Falls back to case-insensitive matching since Docker DNS is case-
|
||||||
|
insensitive but docker inspect is case-sensitive.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance_name: The service name in compose
|
instance_name: The expected container name.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Container ID or None if not found
|
Container ID or None if not found.
|
||||||
"""
|
"""
|
||||||
|
expected = instance_name.lower()
|
||||||
|
|
||||||
|
# Fast path: exact match via docker inspect
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
|
["docker", "inspect", "-f", "{{.Id}}", expected],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
return result.stdout.strip().split("\n")[0]
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
# Fallback: list all containers and do case-insensitive exact match
|
||||||
|
ps_result = subprocess.run(
|
||||||
|
["docker", "ps", "-a", "--format", "{{.Names}}\t{{.ID}}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if ps_result.returncode == 0:
|
||||||
|
for line in ps_result.stdout.strip().splitlines():
|
||||||
|
parts = line.split("\t")
|
||||||
|
if len(parts) == 2:
|
||||||
|
name, cid = parts
|
||||||
|
if name.lower() == expected:
|
||||||
|
return cid
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_container_name(instance_name: str) -> str | None:
|
def get_container_name(instance_name: str) -> str | None:
|
||||||
"""Get the full container name for a compose service.
|
"""Get the full container name for a compose service.
|
||||||
|
|
||||||
|
Uses exact name matching via docker inspect to avoid substring collisions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance_name: The service name in compose
|
instance_name: The exact container name (case-insensitive for Docker).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Container name or None if not found
|
Container name or None if not found.
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
|
["docker", "inspect", "-f", "{{.Name}}", instance_name.lower()],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
return result.stdout.strip().split("\n")[0]
|
return result.stdout.strip().lstrip("/")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
|
def get_backend_network_name() -> str:
|
||||||
|
"""Auto-detect the actual Docker network name for the backend network.
|
||||||
|
|
||||||
|
Docker Compose prefixes network names with the project directory name
|
||||||
|
(e.g. 'headquarter_backend' instead of 'backend'). We inspect the API
|
||||||
|
container itself to find the real network name it's connected to.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The actual Docker network name, or 'backend' as fallback.
|
||||||
|
"""
|
||||||
|
# Try to find the API container by its known name
|
||||||
|
api_container = "hq-api"
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
"-f",
|
||||||
|
"{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}",
|
||||||
|
api_container,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
networks = result.stdout.strip().split()
|
||||||
|
for net in networks:
|
||||||
|
if "backend" in net.lower():
|
||||||
|
return net
|
||||||
|
# API container is on some network — return the first one
|
||||||
|
return networks[0]
|
||||||
|
return "backend"
|
||||||
|
|
||||||
|
|
||||||
|
def connect_container_to_network(
|
||||||
|
container_name: str, network_name: str | None = None
|
||||||
|
) -> bool:
|
||||||
"""Connect a Docker container to an existing network.
|
"""Connect a Docker container to an existing network.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
container_name: Name or ID of the container
|
container_name: Name or ID of the container
|
||||||
network_name: Name of the Docker network (default: backend)
|
network_name: Name of the Docker network. If None, auto-detects
|
||||||
|
from the API container's own network membership.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if successful, False otherwise
|
True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
|
if network_name is None:
|
||||||
|
network_name = get_backend_network_name()
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "network", "connect", network_name, container_name],
|
["docker", "network", "connect", network_name, container_name],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -62,24 +126,153 @@ def connect_container_to_network(container_name: str, network_name: str = "backe
|
|||||||
return result.returncode == 0
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
def get_container_status(container_id: str) -> str:
|
def get_container_ip_on_network(
|
||||||
|
container_id: str, network_name: str | None = None
|
||||||
|
) -> str | None:
|
||||||
|
"""Get a container's IP address on a specific Docker network.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID or name.
|
||||||
|
network_name: Network name. If None, auto-detects from the API container.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
IP address string, or None if the container is not on that network.
|
||||||
|
"""
|
||||||
|
if network_name is None:
|
||||||
|
network_name = get_backend_network_name()
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
"-f",
|
||||||
|
f"{{{{.NetworkSettings.Networks.{network_name}.IPAddress}}}}",
|
||||||
|
container_id,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
ip = result.stdout.strip()
|
||||||
|
if ip and ip != "<no value>":
|
||||||
|
return ip
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_container_on_network(container_id: str, network_name: str | None = None) -> bool:
|
||||||
|
"""Check whether a container is already attached to a Docker network.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID or name.
|
||||||
|
network_name: Network name. If None, auto-detects from the API container.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the container is on the network.
|
||||||
|
"""
|
||||||
|
if network_name is None:
|
||||||
|
network_name = get_backend_network_name()
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
"-f",
|
||||||
|
f"{{{{.NetworkSettings.Networks.{network_name}}}}}",
|
||||||
|
container_id,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.returncode == 0 and "<no value>" not in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_status(container_id: str) -> dict[str, Any]:
|
||||||
"""Get the status of a Docker container.
|
"""Get the status of a Docker container.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
container_id: Docker container ID
|
container_id: Docker container ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Container status string (running, exited, etc.)
|
Dict with 'status' (running, exited, restarting, not_found),
|
||||||
|
'exit_code' (int or None), and 'health' (health status or None)
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "inspect", "-f", "{{.State.Status}}", container_id],
|
[
|
||||||
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
"-f",
|
||||||
|
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
||||||
|
container_id,
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode != 0:
|
||||||
return result.stdout.strip()
|
return {"status": "not_found", "exit_code": None, "health": None}
|
||||||
return "unknown"
|
|
||||||
|
parts = result.stdout.strip().split("|")
|
||||||
|
status = parts[0] if parts else "unknown"
|
||||||
|
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
||||||
|
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
|
||||||
|
|
||||||
|
return {"status": status, "exit_code": exit_code, "health": health}
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_container_running(
|
||||||
|
container_id: str, timeout: int = 30, interval: float = 2.0
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Wait for a container to reach the running state.
|
||||||
|
|
||||||
|
Polls docker inspect until the container status is "running" or timeout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID
|
||||||
|
timeout: Maximum seconds to wait
|
||||||
|
interval: Seconds between polls
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
||||||
|
and 'waited_seconds' (float)
|
||||||
|
"""
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
info = get_container_status(container_id)
|
||||||
|
|
||||||
|
if info["status"] == "running":
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status": "running",
|
||||||
|
"exit_code": None,
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
if info["status"] == "exited":
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"status": "exited",
|
||||||
|
"exit_code": info["exit_code"],
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
if info["status"] == "not_found":
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"status": "not_found",
|
||||||
|
"exit_code": None,
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
# Timeout reached
|
||||||
|
info = get_container_status(container_id)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"status": info["status"],
|
||||||
|
"exit_code": info["exit_code"],
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
||||||
@@ -113,6 +306,8 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
|||||||
Returns:
|
Returns:
|
||||||
Free port number
|
Free port number
|
||||||
"""
|
"""
|
||||||
|
import socket
|
||||||
|
|
||||||
for port in range(start, end):
|
for port in range(start, end):
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
if s.connect_ex(("localhost", port)) != 0:
|
if s.connect_ex(("localhost", port)) != 0:
|
||||||
|
|||||||
@@ -1,146 +1,281 @@
|
|||||||
"""Cloudflare tunnel management for Docker instances."""
|
"""Cloudflare tunnel management using cloudflared Docker containers.
|
||||||
|
|
||||||
|
Each tunnel runs as a Docker container on the same 'backend' network as the API.
|
||||||
|
cloudflared connects to the tool container by its Docker Compose service name
|
||||||
|
(e.g. http://code-server-headquarter-34837cd3:8443).
|
||||||
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import signal
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from src.services.docker.container import get_backend_network_name
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TUNNEL_IMAGE = "cloudflare/cloudflared:latest"
|
||||||
|
|
||||||
def start_cloudflared_tunnel(
|
|
||||||
container_name: str, port: int, timeout: int = 30
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Start a temporary Cloudflare tunnel for a container.
|
|
||||||
|
|
||||||
Uses 'cloudflared tunnel --url' to create a temporary tunnel
|
def _tunnel_container_name(instance_name: str) -> str:
|
||||||
with a random trycloudflare.com URL.
|
return f"tunnel-{instance_name.lower()}"
|
||||||
|
|
||||||
Args:
|
|
||||||
container_name: Name of the Docker container to tunnel to
|
|
||||||
port: Port number the container listens on
|
|
||||||
timeout: Maximum seconds to wait for tunnel URL
|
|
||||||
|
|
||||||
Returns:
|
def _ensure_image() -> None:
|
||||||
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
"""Pull cloudflared image if not already present."""
|
||||||
"""
|
result = subprocess.run(
|
||||||
import select as sel
|
["docker", "images", "-q", TUNNEL_IMAGE],
|
||||||
|
capture_output=True,
|
||||||
# First verify the container is accessible
|
text=True,
|
||||||
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
)
|
||||||
for attempt in range(10):
|
if not result.stdout.strip():
|
||||||
check = subprocess.run(
|
logger.info("Pulling %s ...", TUNNEL_IMAGE)
|
||||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
pull = subprocess.run(
|
||||||
f"http://{container_name}:{port}"],
|
["docker", "pull", TUNNEL_IMAGE],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5,
|
|
||||||
)
|
)
|
||||||
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
|
if pull.returncode != 0:
|
||||||
if check.returncode == 0:
|
logger.warning("Failed to pull %s: %s", TUNNEL_IMAGE, pull.stderr)
|
||||||
break
|
|
||||||
time.sleep(1)
|
|
||||||
else:
|
|
||||||
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
|
|
||||||
|
|
||||||
# Run cloudflared in background, capture output
|
|
||||||
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
def _cleanup_stale_tunnel(tunnel_name: str) -> None:
|
||||||
proc = subprocess.Popen(
|
"""Remove any existing tunnel container with this name."""
|
||||||
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
|
subprocess.run(
|
||||||
stdout=subprocess.PIPE,
|
["docker", "stop", "-t", "3", tunnel_name],
|
||||||
stderr=subprocess.STDOUT,
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", tunnel_name],
|
||||||
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Wait for the URL to appear in output
|
|
||||||
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
|
||||||
start_time = time.time()
|
|
||||||
url = None
|
|
||||||
|
|
||||||
while time.time() - start_time < timeout:
|
def _get_tunnel_logs(tunnel_name: str) -> tuple[str, str]:
|
||||||
# Read available output
|
"""Get stdout and stderr logs from a container."""
|
||||||
readable, _, _ = sel.select([proc.stdout], [], [], 1.0)
|
result = subprocess.run(
|
||||||
if readable:
|
["docker", "logs", tunnel_name],
|
||||||
line = proc.stdout.readline()
|
capture_output=True,
|
||||||
if line:
|
text=True,
|
||||||
match = url_pattern.search(line)
|
)
|
||||||
if match:
|
return result.stdout, result.stderr
|
||||||
url = match.group(0)
|
|
||||||
break
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
proc.terminate()
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Failed to get tunnel URL within {timeout}s. "
|
|
||||||
f"cloudflared output may contain errors."
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"url": url, "pid": str(proc.pid)}
|
|
||||||
|
|
||||||
|
|
||||||
def stop_cloudflared_tunnel(pid: str) -> None:
|
def _get_tunnel_exit_code(tunnel_name: str) -> int | None:
|
||||||
"""Stop a cloudflared tunnel process.
|
"""Get exit code of a container if it has exited."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "inspect", "-f", "{{.State.ExitCode}}", tunnel_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
try:
|
||||||
|
return int(result.stdout.strip())
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def start_tunnel(
|
||||||
|
instance_name: str,
|
||||||
|
container_port: int,
|
||||||
|
timeout: int = 30,
|
||||||
|
target_url: str | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Start a temporary Cloudflare tunnel for an instance.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pid: Process ID of the cloudflared tunnel
|
instance_name: The tool instance name (used for tunnel naming).
|
||||||
|
container_port: The port the tool container listens on internally.
|
||||||
|
timeout: Seconds to wait for the tunnel URL.
|
||||||
|
target_url: Optional explicit URL to proxy to. If omitted, derives
|
||||||
|
http://{instance_name.lower()}:{container_port}.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'url' and 'container_name'.
|
||||||
"""
|
"""
|
||||||
try:
|
_ensure_image()
|
||||||
os.kill(int(pid), signal.SIGTERM)
|
|
||||||
except ProcessLookupError:
|
tunnel_name = _tunnel_container_name(instance_name)
|
||||||
pass # Already stopped
|
_cleanup_stale_tunnel(tunnel_name)
|
||||||
|
|
||||||
|
# Target the tool container by name on the backend network
|
||||||
|
if target_url is None:
|
||||||
|
target_url = f"http://{instance_name.lower()}:{container_port}"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--network",
|
||||||
|
get_backend_network_name(),
|
||||||
|
"--name",
|
||||||
|
tunnel_name,
|
||||||
|
TUNNEL_IMAGE,
|
||||||
|
"tunnel",
|
||||||
|
"--no-autoupdate",
|
||||||
|
"--url",
|
||||||
|
target_url,
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.debug("Running: %s", " ".join(cmd))
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to start tunnel container {tunnel_name}: {proc.stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
container_id = proc.stdout.strip()
|
||||||
|
logger.debug("Tunnel container started: %s", container_id)
|
||||||
|
|
||||||
|
# Wait for URL to appear in logs
|
||||||
|
# Exclude api.trycloudflare.com which is the Cloudflare API endpoint,
|
||||||
|
# not a tunnel URL. Real tunnel URLs have random subdomains (10+ chars).
|
||||||
|
url_pattern = re.compile(r"https://(?!api\.)[a-z0-9-]{10,}\.trycloudflare\.com")
|
||||||
|
start_time = __import__("time").time()
|
||||||
|
url: str | None = None
|
||||||
|
combined_logs = ""
|
||||||
|
|
||||||
|
while __import__("time").time() - start_time < timeout:
|
||||||
|
stdout, stderr = _get_tunnel_logs(tunnel_name)
|
||||||
|
combined_logs = stdout + "\n" + stderr
|
||||||
|
|
||||||
|
match = url_pattern.search(combined_logs)
|
||||||
|
if match:
|
||||||
|
url = match.group(0)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if container exited early
|
||||||
|
exit_code = _get_tunnel_exit_code(tunnel_name)
|
||||||
|
if exit_code is not None and exit_code != 0:
|
||||||
|
_cleanup_stale_tunnel(tunnel_name)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Tunnel container {tunnel_name} exited with code {exit_code}. "
|
||||||
|
f"Logs:\n{combined_logs[-3000:]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
__import__("time").sleep(0.5)
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
stdout, stderr = _get_tunnel_logs(tunnel_name)
|
||||||
|
combined_logs = stdout + "\n" + stderr
|
||||||
|
exit_code = _get_tunnel_exit_code(tunnel_name)
|
||||||
|
|
||||||
|
_cleanup_stale_tunnel(tunnel_name)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Tunnel {tunnel_name} did not produce a URL within {timeout}s. "
|
||||||
|
f"Exit code: {exit_code}. Logs:\n{combined_logs[-3000:]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait a moment for Cloudflare DNS edge to propagate the new tunnel subdomain
|
||||||
|
__import__("time").sleep(2)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Tunnel %s started for %s → %s (%s)",
|
||||||
|
tunnel_name,
|
||||||
|
instance_name,
|
||||||
|
target_url,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
return {"url": url, "container_name": tunnel_name}
|
||||||
|
|
||||||
|
|
||||||
|
def stop_tunnel(instance_name: str) -> None:
|
||||||
|
"""Stop and remove the tunnel container for an instance."""
|
||||||
|
tunnel_name = _tunnel_container_name(instance_name)
|
||||||
|
_cleanup_stale_tunnel(tunnel_name)
|
||||||
|
logger.debug("Stopped and removed tunnel container %s", tunnel_name)
|
||||||
|
|
||||||
|
|
||||||
def recreate_tunnel(
|
def recreate_tunnel(
|
||||||
container_name: str, port: int, old_pid: str | None = None
|
instance_name: str, container_port: int, target_url: str | None = None
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Recreate a temporary Cloudflare tunnel.
|
"""Recreate a tunnel for an instance.
|
||||||
|
|
||||||
Stops the old tunnel (if pid provided) and starts a new one.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
container_name: Name of the Docker container to tunnel to
|
instance_name: The tool instance name.
|
||||||
port: Port number the container listens on
|
container_port: The port the tool container listens on internally.
|
||||||
old_pid: Optional PID of the old tunnel process to stop
|
target_url: Optional explicit origin URL. If omitted, derives
|
||||||
|
http://{instance_name.lower()}:{container_port}.
|
||||||
Returns:
|
|
||||||
Dict with 'url' and 'pid' for the new tunnel
|
|
||||||
"""
|
"""
|
||||||
if old_pid:
|
stop_tunnel(instance_name)
|
||||||
stop_cloudflared_tunnel(old_pid)
|
return start_tunnel(instance_name, container_port, target_url=target_url)
|
||||||
|
|
||||||
return start_cloudflared_tunnel(container_name, port)
|
|
||||||
|
|
||||||
|
|
||||||
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||||
"""Check if a tunnel URL is healthy.
|
"""Check if a tunnel URL is healthy.
|
||||||
|
|
||||||
Args:
|
|
||||||
url: The tunnel URL to check
|
|
||||||
timeout: Request timeout in seconds
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with 'healthy' (bool) and 'status_code' (int or None)
|
Dict with 'tunnel_status', 'status_code', 'healthy', 'error'.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
[
|
||||||
"--max-time", str(timeout), url],
|
"curl",
|
||||||
|
"-s",
|
||||||
|
"-o",
|
||||||
|
"/dev/null",
|
||||||
|
"-w",
|
||||||
|
"%{http_code}",
|
||||||
|
"--max-time",
|
||||||
|
str(timeout),
|
||||||
|
url,
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout + 5,
|
timeout=timeout + 5,
|
||||||
)
|
)
|
||||||
status_code = int(result.stdout.strip())
|
status_code = int(result.stdout.strip())
|
||||||
|
|
||||||
|
if 200 <= status_code < 400:
|
||||||
|
return {
|
||||||
|
"tunnel_status": "healthy",
|
||||||
|
"status_code": status_code,
|
||||||
|
"healthy": True,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
if status_code in (502, 503, 504):
|
||||||
|
return {
|
||||||
|
"tunnel_status": "error_response",
|
||||||
|
"status_code": status_code,
|
||||||
|
"healthy": False,
|
||||||
|
"error": f"Application returned HTTP {status_code}",
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"healthy": 200 <= status_code < 400,
|
"tunnel_status": "error_response",
|
||||||
"status_code": status_code,
|
"status_code": status_code,
|
||||||
}
|
|
||||||
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
|
|
||||||
return {
|
|
||||||
"healthy": False,
|
"healthy": False,
|
||||||
"status_code": None,
|
"error": f"HTTP {status_code}",
|
||||||
"error": str(e),
|
}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {
|
||||||
|
"tunnel_status": "unreachable",
|
||||||
|
"status_code": None,
|
||||||
|
"healthy": False,
|
||||||
|
"error": "Tunnel request timed out",
|
||||||
|
}
|
||||||
|
except (ValueError, Exception) as exc:
|
||||||
|
error_str = str(exc).lower()
|
||||||
|
if any(
|
||||||
|
err in error_str
|
||||||
|
for err in [
|
||||||
|
"connection refused",
|
||||||
|
"econnrefused",
|
||||||
|
"could not resolve",
|
||||||
|
"nodename",
|
||||||
|
]
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"tunnel_status": "unreachable",
|
||||||
|
"status_code": None,
|
||||||
|
"healthy": False,
|
||||||
|
"error": f"Tunnel unreachable: {exc}",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"tunnel_status": "unreachable",
|
||||||
|
"status_code": None,
|
||||||
|
"healthy": False,
|
||||||
|
"error": str(exc),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,19 @@
|
|||||||
"""Git services package."""
|
"""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,196 +0,0 @@
|
|||||||
"""Git control operations with repo validation."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.models.git_repository import GitRepository
|
|
||||||
from src.models.user import User
|
|
||||||
from src.schemas.git_repository import (
|
|
||||||
BranchCreateRequest,
|
|
||||||
CheckoutRequest,
|
|
||||||
CommitRequest,
|
|
||||||
FetchResponse,
|
|
||||||
MergeRequest,
|
|
||||||
MergeResponse,
|
|
||||||
PullResponse,
|
|
||||||
PushResponse,
|
|
||||||
StatusResponse,
|
|
||||||
)
|
|
||||||
from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate
|
|
||||||
from src.utils.git_control import (
|
|
||||||
checkout_branch,
|
|
||||||
commit_changes,
|
|
||||||
create_branch,
|
|
||||||
delete_branch,
|
|
||||||
fetch,
|
|
||||||
get_status,
|
|
||||||
merge,
|
|
||||||
pull,
|
|
||||||
push,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_status_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
) -> StatusResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
result = get_status(repo.path)
|
|
||||||
return StatusResponse(
|
|
||||||
branch=result.branch,
|
|
||||||
modified=result.modified,
|
|
||||||
added=result.added,
|
|
||||||
deleted=result.deleted,
|
|
||||||
untracked=result.untracked,
|
|
||||||
renamed=result.renamed,
|
|
||||||
ahead=result.ahead,
|
|
||||||
behind=result.behind,
|
|
||||||
)
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def create_branch_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: BranchCreateRequest,
|
|
||||||
) -> dict:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
create_branch(repo.path, data.name, data.base_branch)
|
|
||||||
return {"message": f"Branch '{data.name}' created", "branch": data.name}
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_branch_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch_name: str,
|
|
||||||
force: bool = False,
|
|
||||||
) -> dict:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
delete_branch(repo.path, branch_name, force)
|
|
||||||
return {"message": f"Branch '{branch_name}' deleted"}
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def checkout_branch_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: CheckoutRequest,
|
|
||||||
) -> dict:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
checkout_branch(repo.path, data.branch)
|
|
||||||
return {"message": f"Checked out branch '{data.branch}'", "branch": data.branch}
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def commit_changes_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: CommitRequest,
|
|
||||||
user: User,
|
|
||||||
) -> dict:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
author_name = user.name or "Unknown"
|
|
||||||
author_email = user.email or "unknown@example.com"
|
|
||||||
try:
|
|
||||||
commit_hash = commit_changes(
|
|
||||||
repo_path=repo.path,
|
|
||||||
message=data.message,
|
|
||||||
author_name=author_name,
|
|
||||||
author_email=author_email,
|
|
||||||
files=data.files,
|
|
||||||
)
|
|
||||||
return {"commit_hash": commit_hash, "message": data.message}
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
) -> FetchResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
fetch(repo.path)
|
|
||||||
return FetchResponse(message="Fetched from remote")
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def pull_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch: str | None = None,
|
|
||||||
) -> PullResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
pull(repo.path, branch)
|
|
||||||
return PullResponse(message="Pulled from remote")
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def push_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch: str | None = None,
|
|
||||||
) -> PushResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
push(repo.path, branch)
|
|
||||||
return PushResponse(message="Pushed to remote")
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def merge_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: MergeRequest,
|
|
||||||
) -> MergeResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
commit_hash = merge(
|
|
||||||
repo_path=repo.path,
|
|
||||||
source_branch=data.source_branch,
|
|
||||||
target_branch=data.target_branch,
|
|
||||||
message=data.message,
|
|
||||||
)
|
|
||||||
return MergeResponse(
|
|
||||||
commit_hash=commit_hash,
|
|
||||||
message=data.message or f"Merge {data.source_branch}",
|
|
||||||
)
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
"""Git file operations with repo validation."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.models.git_repository import GitRepository
|
|
||||||
from src.models.user import User
|
|
||||||
from src.schemas.git_repository import (
|
|
||||||
FileContentResponse,
|
|
||||||
FileListResponse,
|
|
||||||
FileUpdateRequest,
|
|
||||||
FileUpdateResponse,
|
|
||||||
)
|
|
||||||
from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate
|
|
||||||
from src.utils.git_files import (
|
|
||||||
commit_file,
|
|
||||||
get_file_content,
|
|
||||||
list_branches,
|
|
||||||
list_tree,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_files(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch: str = "main",
|
|
||||||
path: str = "",
|
|
||||||
) -> FileListResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
entries = list_tree(repo.path, branch=branch, path=path)
|
|
||||||
return FileListResponse(
|
|
||||||
path=path,
|
|
||||||
branch=branch,
|
|
||||||
entries=[
|
|
||||||
{
|
|
||||||
"name": e.name,
|
|
||||||
"type": e.type,
|
|
||||||
"path": e.path,
|
|
||||||
"size": e.size,
|
|
||||||
"mode": e.mode,
|
|
||||||
"last_commit": e.last_commit,
|
|
||||||
}
|
|
||||||
for e in entries
|
|
||||||
],
|
|
||||||
)
|
|
||||||
except RuntimeError as e:
|
|
||||||
logger.error(
|
|
||||||
"Failed to list files for repo %s (path=%s, branch=%s): %s",
|
|
||||||
repo_id,
|
|
||||||
path,
|
|
||||||
branch,
|
|
||||||
str(e),
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def get_file(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch: str,
|
|
||||||
path: str,
|
|
||||||
) -> FileContentResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
file_content = get_file_content(repo.path, branch=branch, path=path)
|
|
||||||
return FileContentResponse(
|
|
||||||
path=file_content.path,
|
|
||||||
branch=file_content.branch,
|
|
||||||
content=file_content.content,
|
|
||||||
size=file_content.size,
|
|
||||||
encoding=file_content.encoding,
|
|
||||||
language=file_content.language,
|
|
||||||
is_binary=file_content.is_binary,
|
|
||||||
last_commit=file_content.last_commit,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def update_file(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: FileUpdateRequest,
|
|
||||||
user: User,
|
|
||||||
) -> FileUpdateResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
author_name = user.name or "Unknown"
|
|
||||||
author_email = user.email or "unknown@example.com"
|
|
||||||
try:
|
|
||||||
commit_hash = commit_file(
|
|
||||||
repo_path=repo.path,
|
|
||||||
branch=data.branch,
|
|
||||||
path=data.path,
|
|
||||||
content=data.content,
|
|
||||||
commit_message=data.commit_message,
|
|
||||||
author_name=author_name,
|
|
||||||
author_email=author_email,
|
|
||||||
)
|
|
||||||
return FileUpdateResponse(
|
|
||||||
commit_hash=commit_hash,
|
|
||||||
message=data.commit_message,
|
|
||||||
branch=data.branch,
|
|
||||||
)
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def list_branches_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
) -> dict:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
branches, default_branch = list_branches(repo.path)
|
|
||||||
return {
|
|
||||||
"branches": [
|
|
||||||
{
|
|
||||||
"name": b.name,
|
|
||||||
"is_default": b.is_default,
|
|
||||||
"last_commit": b.last_commit,
|
|
||||||
}
|
|
||||||
for b in branches
|
|
||||||
],
|
|
||||||
"default_branch": default_branch,
|
|
||||||
}
|
|
||||||
except RuntimeError as e:
|
|
||||||
logger.error(
|
|
||||||
"Failed to list branches for repo %s: %s",
|
|
||||||
repo_id,
|
|
||||||
str(e),
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from src.models.workspace import Workspace
|
from src.models import Workspace
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user