refactor: slim config_profiles router
- Extract CRUD helpers to services/config/crud_service.py - Extract resolver/default logic to services/config/resolver_service.py - Slim config_profiles.py from 842 to 474 lines Quality gates: py_compile passes
This commit is contained in:
@@ -1,10 +1,7 @@
|
|||||||
"""Config profile API endpoints."""
|
"""Config profile API endpoints."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -12,10 +9,7 @@ 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 import ConfigProfile, ConfigProfileInclude
|
from src.models import ConfigProfile, ConfigProfileInclude, UserConfig
|
||||||
from src.models.project import Project
|
|
||||||
from src.models import ToolType
|
|
||||||
from src.models import UserConfig
|
|
||||||
from src.schemas.config import (
|
from src.schemas.config import (
|
||||||
ConfigProfileCreate,
|
ConfigProfileCreate,
|
||||||
ConfigProfileIncludeUpdate,
|
ConfigProfileIncludeUpdate,
|
||||||
@@ -31,121 +25,25 @@ from src.services.config.config_profile_resolver import (
|
|||||||
resolve_profile,
|
resolve_profile,
|
||||||
resolved_profile_to_dict,
|
resolved_profile_to_dict,
|
||||||
)
|
)
|
||||||
from src.utils.git_url_parser import parse_git_url
|
from src.services.config.crud_service import (
|
||||||
|
calculate_profile_size,
|
||||||
|
check_access,
|
||||||
|
get_or_create_user_config,
|
||||||
|
get_profile_with_includes,
|
||||||
|
profile_to_response,
|
||||||
|
validate_default_profiles,
|
||||||
|
validate_git_mounts,
|
||||||
|
MAX_PROFILE_SIZE_BYTES,
|
||||||
|
)
|
||||||
|
from src.services.config.resolver_service import (
|
||||||
|
resolve_default_profile,
|
||||||
|
validate_git_url,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
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])
|
@router.get("", response_model=list[ConfigProfileResponse])
|
||||||
async def list_config_profiles(
|
async def list_config_profiles(
|
||||||
@@ -165,26 +63,21 @@ async def list_config_profiles(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if project_id or tool_type_id:
|
if project_id or tool_type_id:
|
||||||
# Compatibility filter: include portable profiles and matching scoped profiles
|
from sqlalchemy import or_
|
||||||
|
|
||||||
project_uuid = uuid.UUID(project_id) if project_id else None
|
project_uuid = uuid.UUID(project_id) if project_id else None
|
||||||
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
|
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
|
||||||
|
|
||||||
from sqlalchemy import or_
|
|
||||||
|
|
||||||
conditions: list = []
|
conditions: list = []
|
||||||
# Portable profiles (no project, no tool)
|
|
||||||
conditions.append(
|
conditions.append(
|
||||||
(ConfigProfile.project_id.is_(None))
|
(ConfigProfile.project_id.is_(None))
|
||||||
& (ConfigProfile.tool_type_id.is_(None))
|
& (ConfigProfile.tool_type_id.is_(None))
|
||||||
)
|
)
|
||||||
if project_uuid:
|
if project_uuid:
|
||||||
# Profiles matching this project (with or without tool)
|
|
||||||
conditions.append(ConfigProfile.project_id == project_uuid)
|
conditions.append(ConfigProfile.project_id == project_uuid)
|
||||||
if tool_uuid:
|
if tool_uuid:
|
||||||
# Profiles matching this tool (with or without project)
|
|
||||||
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
|
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
|
||||||
if project_uuid and tool_uuid:
|
if project_uuid and tool_uuid:
|
||||||
# Exact match
|
|
||||||
conditions.append(
|
conditions.append(
|
||||||
(ConfigProfile.project_id == project_uuid)
|
(ConfigProfile.project_id == project_uuid)
|
||||||
& (ConfigProfile.tool_type_id == tool_uuid)
|
& (ConfigProfile.tool_type_id == tool_uuid)
|
||||||
@@ -194,7 +87,7 @@ async def list_config_profiles(
|
|||||||
|
|
||||||
result = await session.execute(query)
|
result = await session.execute(query)
|
||||||
profiles = result.scalars().all()
|
profiles = result.scalars().all()
|
||||||
return [_profile_to_response(p) for p in profiles]
|
return [profile_to_response(p) for p in profiles]
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -208,7 +101,6 @@ async def create_config_profile(
|
|||||||
"""Create a new config profile."""
|
"""Create a new config profile."""
|
||||||
user_uuid = current_user_id
|
user_uuid = current_user_id
|
||||||
|
|
||||||
# Check for duplicate name
|
|
||||||
existing = await session.execute(
|
existing = await session.execute(
|
||||||
select(ConfigProfile)
|
select(ConfigProfile)
|
||||||
.where(
|
.where(
|
||||||
@@ -223,24 +115,21 @@ async def create_config_profile(
|
|||||||
detail=f"Profile with name '{data.name}' already exists",
|
detail=f"Profile with name '{data.name}' already exists",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate references
|
|
||||||
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
|
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
|
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)
|
await check_access(session, user_uuid, project_uuid, tool_uuid)
|
||||||
|
|
||||||
# Validate git mounts reference existing repositories
|
|
||||||
if data.git_mounts:
|
if data.git_mounts:
|
||||||
git_mounts_data = [
|
git_mounts_data = [
|
||||||
m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts
|
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)
|
await validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
|
||||||
|
|
||||||
# Check size
|
size = calculate_profile_size(data.model_dump())
|
||||||
size = _calculate_profile_size(data.model_dump())
|
|
||||||
if size > MAX_PROFILE_SIZE_BYTES:
|
if size > MAX_PROFILE_SIZE_BYTES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
|
detail="Profile size exceeds 10MB limit",
|
||||||
)
|
)
|
||||||
|
|
||||||
profile = ConfigProfile(
|
profile = ConfigProfile(
|
||||||
@@ -259,7 +148,6 @@ async def create_config_profile(
|
|||||||
session.add(profile)
|
session.add(profile)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Re-fetch with includes to avoid lazy loading issues
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ConfigProfile)
|
select(ConfigProfile)
|
||||||
.where(ConfigProfile.id == profile.id)
|
.where(ConfigProfile.id == profile.id)
|
||||||
@@ -268,7 +156,7 @@ async def create_config_profile(
|
|||||||
profile = result.scalar_one()
|
profile = result.scalar_one()
|
||||||
|
|
||||||
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
|
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
|
||||||
return _profile_to_response(profile)
|
return profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
|
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
|
||||||
@@ -278,7 +166,7 @@ async def get_config_profile(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Get a config profile by ID."""
|
"""Get a config profile by ID."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
@@ -287,7 +175,7 @@ async def get_config_profile(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
)
|
)
|
||||||
return _profile_to_response(profile)
|
return profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
|
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
|
||||||
@@ -298,7 +186,7 @@ async def update_config_profile(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Update a config profile."""
|
"""Update a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
@@ -310,7 +198,6 @@ async def update_config_profile(
|
|||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
# Handle name uniqueness
|
|
||||||
if "name" in update_data:
|
if "name" in update_data:
|
||||||
existing = await session.execute(
|
existing = await session.execute(
|
||||||
select(ConfigProfile).where(
|
select(ConfigProfile).where(
|
||||||
@@ -325,7 +212,6 @@ async def update_config_profile(
|
|||||||
detail=f"Profile with name '{update_data['name']}' already exists",
|
detail=f"Profile with name '{update_data['name']}' already exists",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate references
|
|
||||||
project_uuid = (
|
project_uuid = (
|
||||||
uuid.UUID(update_data["project_id"])
|
uuid.UUID(update_data["project_id"])
|
||||||
if "project_id" in update_data and update_data["project_id"]
|
if "project_id" in update_data and update_data["project_id"]
|
||||||
@@ -336,29 +222,26 @@ async def update_config_profile(
|
|||||||
if "tool_type_id" in update_data and 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)
|
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)
|
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:
|
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
|
||||||
git_mounts_data = [
|
git_mounts_data = [
|
||||||
m.model_dump() if hasattr(m, "model_dump") else m
|
m.model_dump() if hasattr(m, "model_dump") else m
|
||||||
for m in update_data["git_mounts"]
|
for m in update_data["git_mounts"]
|
||||||
]
|
]
|
||||||
await _validate_git_mounts(
|
await validate_git_mounts(
|
||||||
session, profile.user_id, git_mounts_data, project_uuid
|
session, profile.user_id, git_mounts_data, project_uuid
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check size
|
current_data = profile_to_response(profile)
|
||||||
current_data = _profile_to_response(profile)
|
|
||||||
merged = {**current_data, **update_data}
|
merged = {**current_data, **update_data}
|
||||||
size = _calculate_profile_size(merged)
|
size = calculate_profile_size(merged)
|
||||||
if size > MAX_PROFILE_SIZE_BYTES:
|
if size > MAX_PROFILE_SIZE_BYTES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
|
detail="Profile size exceeds 10MB limit",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply updates
|
|
||||||
for field_name, value in update_data.items():
|
for field_name, value in update_data.items():
|
||||||
if field_name in ("project_id", "tool_type_id"):
|
if field_name in ("project_id", "tool_type_id"):
|
||||||
value = uuid.UUID(value) if value else None
|
value = uuid.UUID(value) if value else None
|
||||||
@@ -370,7 +253,6 @@ async def update_config_profile(
|
|||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Re-fetch with includes to avoid lazy loading issues
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ConfigProfile)
|
select(ConfigProfile)
|
||||||
.where(ConfigProfile.id == profile.id)
|
.where(ConfigProfile.id == profile.id)
|
||||||
@@ -379,7 +261,7 @@ async def update_config_profile(
|
|||||||
profile = result.scalar_one()
|
profile = result.scalar_one()
|
||||||
|
|
||||||
logger.debug("Updated config profile %s", profile.id)
|
logger.debug("Updated config profile %s", profile.id)
|
||||||
return _profile_to_response(profile)
|
return profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -389,7 +271,7 @@ async def delete_config_profile(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Delete a config profile."""
|
"""Delete a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
@@ -414,7 +296,7 @@ async def update_profile_includes(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Update the ordered includes for a config profile."""
|
"""Update the ordered includes for a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
@@ -424,7 +306,6 @@ async def update_profile_includes(
|
|||||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
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]
|
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
||||||
for inc_uuid in included_uuids:
|
for inc_uuid in included_uuids:
|
||||||
inc_profile = await session.get(ConfigProfile, inc_uuid)
|
inc_profile = await session.get(ConfigProfile, inc_uuid)
|
||||||
@@ -444,10 +325,8 @@ async def update_profile_includes(
|
|||||||
detail="Profile cannot include itself",
|
detail="Profile cannot include itself",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for cycles
|
|
||||||
cycle = await check_include_cycle(session, profile.id, None)
|
cycle = await check_include_cycle(session, profile.id, None)
|
||||||
if cycle is None and included_uuids:
|
if cycle is None and included_uuids:
|
||||||
# Check each new include would not create a cycle
|
|
||||||
for inc_uuid in included_uuids:
|
for inc_uuid in included_uuids:
|
||||||
cycle = await check_include_cycle(session, profile.id, inc_uuid)
|
cycle = await check_include_cycle(session, profile.id, inc_uuid)
|
||||||
if cycle is not None:
|
if cycle is not None:
|
||||||
@@ -460,7 +339,6 @@ async def update_profile_includes(
|
|||||||
detail=f"Include cycle detected: {cycle_str}",
|
detail=f"Include cycle detected: {cycle_str}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Remove existing includes
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ConfigProfileInclude).where(
|
select(ConfigProfileInclude).where(
|
||||||
ConfigProfileInclude.profile_id == profile.id
|
ConfigProfileInclude.profile_id == profile.id
|
||||||
@@ -470,7 +348,6 @@ async def update_profile_includes(
|
|||||||
await session.delete(existing)
|
await session.delete(existing)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
# Add new includes
|
|
||||||
for order_index, inc_uuid in enumerate(included_uuids):
|
for order_index, inc_uuid in enumerate(included_uuids):
|
||||||
include = ConfigProfileInclude(
|
include = ConfigProfileInclude(
|
||||||
profile_id=profile.id,
|
profile_id=profile.id,
|
||||||
@@ -479,10 +356,8 @@ async def update_profile_includes(
|
|||||||
)
|
)
|
||||||
session.add(include)
|
session.add(include)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Re-fetch profile (includes loaded separately due to SQLite async issue)
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ConfigProfile).where(ConfigProfile.id == profile.id)
|
select(ConfigProfile).where(ConfigProfile.id == profile.id)
|
||||||
)
|
)
|
||||||
@@ -496,7 +371,7 @@ async def update_profile_includes(
|
|||||||
direct_includes = inc_result.scalars().all()
|
direct_includes = inc_result.scalars().all()
|
||||||
|
|
||||||
logger.debug("Updated includes for config profile %s", profile.id)
|
logger.debug("Updated includes for config profile %s", profile.id)
|
||||||
return _profile_to_response(profile, list(direct_includes))
|
return profile_to_response(profile, list(direct_includes))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{profile_id}/preview")
|
@router.get("/{profile_id}/preview")
|
||||||
@@ -506,7 +381,7 @@ async def preview_config_profile(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Preview the resolved output of a config profile."""
|
"""Preview the resolved output of a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
@@ -528,120 +403,19 @@ async def preview_config_profile(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/defaults/resolve")
|
@router.get("/defaults/resolve")
|
||||||
async def resolve_default_profile(
|
async def resolve_default_profile_endpoint(
|
||||||
project_id: str = Query(..., description="Project ID"),
|
project_id: str = Query(..., description="Project ID"),
|
||||||
tool_type_id: str = Query(..., description="Tool type ID"),
|
tool_type_id: str = Query(..., description="Tool type ID"),
|
||||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Resolve the default config profile for a project/tool combination.
|
"""Resolve the default config profile for a project/tool combination."""
|
||||||
|
return await resolve_default_profile(
|
||||||
Selects by specificity:
|
session,
|
||||||
1. project+tool explicit default
|
current_user_id,
|
||||||
2. project explicit default
|
uuid.UUID(project_id),
|
||||||
3. tool explicit default
|
uuid.UUID(tool_type_id),
|
||||||
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")
|
@router.get("/defaults")
|
||||||
@@ -664,8 +438,8 @@ async def set_default_profiles_endpoint(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Set default profile mappings for the current user."""
|
"""Set default profile mappings for the current user."""
|
||||||
await _validate_default_profiles(session, user_id, data.default_profiles)
|
await validate_default_profiles(session, user_id, data.default_profiles)
|
||||||
user_config = await _get_or_create_user_config(session, user_id)
|
user_config = await get_or_create_user_config(session, user_id)
|
||||||
user_config.config = {
|
user_config.config = {
|
||||||
**user_config.config,
|
**user_config.config,
|
||||||
"default_profiles": data.default_profiles,
|
"default_profiles": data.default_profiles,
|
||||||
@@ -691,152 +465,10 @@ async def get_default_profile_for_tool_type_endpoint(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
|
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
|
||||||
async def validate_git_url(
|
async def validate_git_url_endpoint(
|
||||||
data: ValidateGitUrlRequest,
|
data: ValidateGitUrlRequest,
|
||||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ValidateGitUrlResponse:
|
) -> ValidateGitUrlResponse:
|
||||||
"""Validate a git remote URL and list available branches.
|
"""Validate a git remote URL and list available branches."""
|
||||||
|
return await validate_git_url(session, current_user_id, data.url, data.ssh_key_id)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Config profile CRUD service functions."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from src.models import ConfigProfile, ConfigProfileInclude, ToolType, UserConfig
|
||||||
|
from src.models.project import Project
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
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."""
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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}",
|
||||||
|
)
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Config profile resolver service functions."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models import ConfigProfile, SSHKey, UserConfig
|
||||||
|
from src.services.shared.ssh_keys import _get_fernet
|
||||||
|
from src.utils.git_url_parser import parse_git_url
|
||||||
|
from src.schemas.config import ValidateGitUrlResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_default_profile(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
tool_type_id: uuid.UUID,
|
||||||
|
) -> dict:
|
||||||
|
"""Resolve the default config profile for a project/tool combination."""
|
||||||
|
query = (
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.user_id == user_id)
|
||||||
|
.where(
|
||||||
|
(ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None))
|
||||||
|
| (ConfigProfile.project_id == project_id)
|
||||||
|
| (ConfigProfile.tool_type_id == tool_type_id)
|
||||||
|
| (
|
||||||
|
(ConfigProfile.project_id == project_id)
|
||||||
|
& (ConfigProfile.tool_type_id == tool_type_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(ConfigProfile.created_at)
|
||||||
|
)
|
||||||
|
result = await session.execute(query)
|
||||||
|
profiles = result.scalars().all()
|
||||||
|
|
||||||
|
if not profiles:
|
||||||
|
return {"profile_id": None, "profile_name": None}
|
||||||
|
|
||||||
|
explicit_defaults = [p for p in profiles if p.is_default]
|
||||||
|
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id == project_id and p.tool_type_id == tool_type_id:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id == project_id and p.tool_type_id is None:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id is None and p.tool_type_id == tool_type_id:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
first = profiles[0]
|
||||||
|
return {"profile_id": str(first.id), "profile_name": first.name}
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_git_url(
|
||||||
|
session: AsyncSession,
|
||||||
|
current_user_id: uuid.UUID,
|
||||||
|
url: str,
|
||||||
|
ssh_key_id: str | None,
|
||||||
|
) -> ValidateGitUrlResponse:
|
||||||
|
"""Validate a git remote URL and list available branches."""
|
||||||
|
parse_result = parse_git_url(url)
|
||||||
|
original_url = 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 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"),
|
||||||
|
)
|
||||||
|
|
||||||
|
env = None
|
||||||
|
key_path = None
|
||||||
|
if ssh_key_id:
|
||||||
|
try:
|
||||||
|
ssh_key_uuid = uuid.UUID(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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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]
|
||||||
|
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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user