diff --git a/apps/api/src/api/config/config_profiles.py b/apps/api/src/api/config/config_profiles.py index 5b0d3d6..4a311a0 100644 --- a/apps/api/src/api/config/config_profiles.py +++ b/apps/api/src/api/config/config_profiles.py @@ -1,10 +1,7 @@ """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 @@ -12,10 +9,7 @@ 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.models import ConfigProfile, ConfigProfileInclude, UserConfig from src.schemas.config import ( ConfigProfileCreate, ConfigProfileIncludeUpdate, @@ -31,121 +25,25 @@ from src.services.config.config_profile_resolver import ( resolve_profile, 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__) 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( @@ -165,26 +63,21 @@ async def list_config_profiles( ) 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 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) @@ -194,7 +87,7 @@ async def list_config_profiles( result = await session.execute(query) profiles = result.scalars().all() - return [_profile_to_response(p) for p in profiles] + return [profile_to_response(p) for p in profiles] @router.post( @@ -208,7 +101,6 @@ async def create_config_profile( """Create a new config profile.""" user_uuid = current_user_id - # Check for duplicate name existing = await session.execute( select(ConfigProfile) .where( @@ -223,24 +115,21 @@ async def create_config_profile( 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) + 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) + 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: raise HTTPException( 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( @@ -259,7 +148,6 @@ async def create_config_profile( 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) @@ -268,7 +156,7 @@ async def create_config_profile( profile = result.scalar_one() 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) @@ -278,7 +166,7 @@ async def get_config_profile( session: AsyncSession = Depends(get_db_session), ): """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: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found" @@ -287,7 +175,7 @@ async def get_config_profile( raise HTTPException( 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) @@ -298,7 +186,7 @@ async def update_config_profile( session: AsyncSession = Depends(get_db_session), ): """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: raise HTTPException( 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) - # Handle name uniqueness if "name" in update_data: existing = await session.execute( select(ConfigProfile).where( @@ -325,7 +212,6 @@ async def update_config_profile( 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"] @@ -336,29 +222,26 @@ async def update_config_profile( 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) + 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( + await validate_git_mounts( 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} - size = _calculate_profile_size(merged) + 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", + detail="Profile size exceeds 10MB 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 @@ -370,7 +253,6 @@ async def update_config_profile( await session.commit() - # Re-fetch with includes to avoid lazy loading issues result = await session.execute( select(ConfigProfile) .where(ConfigProfile.id == profile.id) @@ -379,7 +261,7 @@ async def update_config_profile( profile = result.scalar_one() 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) @@ -389,7 +271,7 @@ async def delete_config_profile( session: AsyncSession = Depends(get_db_session), ): """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: raise HTTPException( 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), ): """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: raise HTTPException( 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" ) - # 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) @@ -444,10 +325,8 @@ async def update_profile_includes( 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: @@ -460,7 +339,6 @@ async def update_profile_includes( detail=f"Include cycle detected: {cycle_str}", ) - # Remove existing includes result = await session.execute( select(ConfigProfileInclude).where( ConfigProfileInclude.profile_id == profile.id @@ -470,7 +348,6 @@ async def update_profile_includes( 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, @@ -479,10 +356,8 @@ async def update_profile_includes( ) 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) ) @@ -496,7 +371,7 @@ async def update_profile_includes( direct_includes = inc_result.scalars().all() 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") @@ -506,7 +381,7 @@ async def preview_config_profile( 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)) + 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" @@ -528,120 +403,19 @@ async def preview_config_profile( @router.get("/defaults/resolve") -async def resolve_default_profile( +async def resolve_default_profile_endpoint( 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) + """Resolve the default config profile for a project/tool combination.""" + return await resolve_default_profile( + session, + current_user_id, + uuid.UUID(project_id), + uuid.UUID(tool_type_id), ) - 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") @@ -664,8 +438,8 @@ async def set_default_profiles_endpoint( 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) + 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, @@ -691,152 +465,10 @@ async def get_default_profile_for_tool_type_endpoint( @router.post("/validate-git-url", response_model=ValidateGitUrlResponse) -async def validate_git_url( +async def validate_git_url_endpoint( 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, - ) + """Validate a git remote URL and list available branches.""" + return await validate_git_url(session, current_user_id, data.url, data.ssh_key_id) diff --git a/apps/api/src/services/config/crud_service.py b/apps/api/src/services/config/crud_service.py new file mode 100644 index 0000000..da56068 --- /dev/null +++ b/apps/api/src/services/config/crud_service.py @@ -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}", + ) diff --git a/apps/api/src/services/config/resolver_service.py b/apps/api/src/services/config/resolver_service.py new file mode 100644 index 0000000..20a2514 --- /dev/null +++ b/apps/api/src/services/config/resolver_service.py @@ -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, + )