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:
Developer
2026-06-05 19:55:09 +00:00
parent 9503f6cb4f
commit de6a6a3b00
3 changed files with 413 additions and 416 deletions
+48 -416
View File
@@ -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)