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
@@ -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,
)