"""Config profile API endpoints.""" import logging import uuid from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from src.auth.dependencies import get_current_user_id, get_db_session from src.models.config_profile import ConfigProfile, ConfigProfileInclude from src.models.project import Project from src.models.tool_type import ToolType from src.services.config_profile_resolver import ( ConfigProfileCycleError, check_include_cycle, resolve_profile, resolved_profile_to_dict, ) 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 _validate_uuid(v: str | None) -> str | None: if v is None: return v try: uuid.UUID(v) except ValueError: raise ValueError(f"Invalid UUID: {v}") return v def _calculate_profile_size(data: dict) -> int: """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 class MountItem(BaseModel): target: str = Field(description="Absolute mount target path") mode: str = Field(default="rw", description="Mount mode: ro or rw") files: dict = Field(default_factory=dict, description="Files as {relative_path: content}") @field_validator("target") @classmethod def validate_target(cls, v: str) -> str: if not v.startswith("/"): raise ValueError("Mount target must be absolute (start with /)") return v @field_validator("mode") @classmethod def validate_mode(cls, v: str) -> str: if v not in ("ro", "rw"): raise ValueError("Mount mode must be 'ro' or 'rw'") return v @field_validator("files") @classmethod def validate_files(cls, v: dict) -> dict: for path in v.keys(): if ".." in path or not path: raise ValueError(f"Invalid file path: {path}") if path.startswith("/"): raise ValueError( f"Mount file paths must be relative (got: {path}). " f"The mount target defines the absolute container path." ) return v class ConfigProfileCreate(BaseModel): name: str = Field(description="Profile name (unique per user)") description: str | None = Field(default=None, description="Optional description") project_id: str | None = Field(default=None, description="Optional project ID") tool_type_id: str | None = Field(default=None, description="Optional tool type ID") env_vars: dict = Field(default_factory=dict, description="Environment variables") runtime_hints: dict = Field(default_factory=dict, description="Runtime hints") mounts: list[MountItem] = Field(default_factory=list, description="Mount definitions") files: dict = Field(default_factory=dict, description="Files as {relative_path: content}") is_default: bool = Field(default=False, description="Whether this is the default profile for its scope") @field_validator("project_id", "tool_type_id") @classmethod def validate_uuids(cls, v: str | None) -> str | None: return _validate_uuid(v) @field_validator("files") @classmethod def validate_files(cls, v: dict) -> dict: for path in v.keys(): if ".." in path or not path: raise ValueError(f"Invalid file path: {path}") if path.startswith("/"): raise ValueError( f"File paths must be relative (got: {path}). " f"Use Mounts for absolute container paths." ) return v @field_validator("env_vars") @classmethod def validate_env_vars(cls, v: dict) -> dict: if not isinstance(v, dict): raise ValueError("env_vars must be a JSON object") return v @field_validator("runtime_hints") @classmethod def validate_runtime_hints(cls, v: dict) -> dict: if not isinstance(v, dict): raise ValueError("runtime_hints must be a JSON object") return v @field_validator("mounts") @classmethod def validate_mounts(cls, v: list) -> list: if not isinstance(v, list): raise ValueError("mounts must be a JSON array") return v class ConfigProfileUpdate(BaseModel): name: str | None = Field(default=None, description="Profile name") description: str | None = Field(default=None, description="Optional description") project_id: str | None = Field(default=None, description="Optional project ID") tool_type_id: str | None = Field(default=None, description="Optional tool type ID") env_vars: dict | None = Field(default=None, description="Environment variables") runtime_hints: dict | None = Field(default=None, description="Runtime hints") mounts: list[MountItem] | None = Field(default=None, description="Mount definitions") files: dict | None = Field(default=None, description="Files as {relative_path: content}") is_default: bool | None = Field(default=None, description="Whether this is the default profile") @field_validator("project_id", "tool_type_id") @classmethod def validate_uuids(cls, v: str | None) -> str | None: return _validate_uuid(v) @field_validator("files") @classmethod def validate_files(cls, v: dict | None) -> dict | None: if v is None: return v for path in v.keys(): if ".." in path or path.startswith("/") or not path: raise ValueError(f"Invalid file path: {path}") return v class ConfigProfileIncludeUpdate(BaseModel): includes: list[str] = Field(description="Ordered list of included profile IDs") @field_validator("includes") @classmethod def validate_includes(cls, v: list) -> list: for item in v: try: uuid.UUID(item) except ValueError: raise ValueError(f"Invalid UUID in includes: {item}") return v class ConfigProfileResponse(BaseModel): id: str user_id: str name: str description: str | None project_id: str | None tool_type_id: str | None env_vars: dict runtime_hints: dict mounts: list files: dict is_default: bool includes: list[dict] created_at: str updated_at: str 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") 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 [], "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) # 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], 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.info("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) # 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] 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.info("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.info("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.info("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}