37ccaa4fdc
Service organization (19 files moved into 6 subpackages): - services/instance/ — event_bus, health_monitor, lifecycle_hooks - services/config/ — config_profile_resolver - services/git/ — clone, git_operations, git_service - services/build/ — docker_build, manifest_compiler - services/terminal/ — terminal_manager, terminal_session - services/shared/ — correlation, file_service, notification_service, permission_fixer, readiness_probe, ssh_keys, tunnel, workspace_manager API router organization (16 files moved into 6 subpackages): - api/tool/ — tool_instances, tool_types, tool_definitions, tool_types_validation, sessions (extracted from tool_instances) - api/config/ — config_profiles, user_config - api/workspace/ — workspaces, workspace_files, workspace_git, workspace_instances - api/user/ — users, auth, ssh_keys - api/project/ — projects, git_repositories - api/system/ — health, events, notifications, dashboard, terminal, instance_proxy Updated main.py imports and all __init__.py re-exports. Sessions router extracted from tool_instances.py into api/tool/sessions.py. Quality gates: py_compile passed, ruff passed.
843 lines
30 KiB
Python
843 lines
30 KiB
Python
"""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,
|
|
)
|