refactor: slim config_profiles router to 301 lines

Extract CRUD helpers into services/config/crud_service.py.
Move instance-related config logic to services/tool/instance_service.py.

Quality gates: py_compile pass
This commit is contained in:
Developer
2026-06-05 20:56:50 +00:00
parent 88c56a83b7
commit 6efe524974
3 changed files with 511 additions and 185 deletions
+8 -182
View File
@@ -21,19 +21,17 @@ from src.schemas.config import (
)
from src.services.config.config_profile_resolver import (
ConfigProfileCycleError,
check_include_cycle,
resolve_profile,
resolved_profile_to_dict,
)
from src.services.config.crud_service import (
calculate_profile_size,
check_access,
create_profile,
get_or_create_user_config,
get_profile_with_includes,
profile_to_response,
update_includes,
update_profile,
validate_default_profiles,
validate_git_mounts,
MAX_PROFILE_SIZE_BYTES,
)
from src.services.config.resolver_service import (
resolve_default_profile,
@@ -99,63 +97,8 @@ async def create_config_profile(
session: AsyncSession = Depends(get_db_session),
):
"""Create a new config profile."""
user_uuid = current_user_id
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",
)
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)
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)
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="Profile size exceeds 10MB 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],
git_mounts=[m.model_dump() for m in data.git_mounts],
files=data.files,
is_default=data.is_default,
)
session.add(profile)
await session.commit()
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
.options(selectinload(ConfigProfile.includes))
)
profile = result.scalar_one()
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
profile = await create_profile(session, current_user_id, data)
logger.debug("Created config profile %s for user %s", profile.id, current_user_id)
return profile_to_response(profile)
@@ -196,70 +139,7 @@ async def update_config_profile(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
update_data = data.model_dump(exclude_unset=True)
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",
)
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)
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(
session, profile.user_id, git_mounts_data, project_uuid
)
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="Profile size exceeds 10MB limit",
)
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]
elif field_name == "git_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()
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
.options(selectinload(ConfigProfile.includes))
)
profile = result.scalar_one()
profile = await update_profile(session, profile, data)
logger.debug("Updated config profile %s", profile.id)
return profile_to_response(profile)
@@ -289,7 +169,7 @@ async def delete_config_profile(
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
async def update_profile_includes(
async def update_profile_includes_endpoint(
profile_id: str,
data: ConfigProfileIncludeUpdate,
current_user_id: uuid.UUID = Depends(get_current_user_id),
@@ -307,61 +187,7 @@ async def update_profile_includes(
)
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",
)
cycle = await check_include_cycle(session, profile.id, None)
if cycle is None and included_uuids:
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}",
)
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()
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()
result = await session.execute(
select(ConfigProfile).where(ConfigProfile.id == profile.id)
)
profile = result.scalar_one()
profile = await update_includes(session, profile, included_uuids, current_user_id)
inc_result = await session.execute(
select(ConfigProfileInclude).where(
@@ -155,3 +155,201 @@ async def validate_default_profiles(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Profile does not belong to user: {profile_id_str}",
)
async def create_profile(
session: AsyncSession,
user_id: uuid.UUID,
data: Any,
) -> ConfigProfile:
"""Create a new config profile after validation."""
existing = await session.execute(
select(ConfigProfile)
.where(
ConfigProfile.user_id == user_id,
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",
)
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_id, project_uuid, tool_uuid)
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_id, git_mounts_data, project_uuid)
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="Profile size exceeds 10MB limit",
)
profile = ConfigProfile(
user_id=user_id,
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],
git_mounts=[m.model_dump() for m in data.git_mounts],
files=data.files,
is_default=data.is_default,
)
session.add(profile)
await session.commit()
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
.options(selectinload(ConfigProfile.includes))
)
return result.scalar_one()
async def update_profile(
session: AsyncSession,
profile: ConfigProfile,
data: Any,
) -> ConfigProfile:
"""Update a config profile after validation."""
update_data = data.model_dump(exclude_unset=True)
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",
)
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)
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(
session, profile.user_id, git_mounts_data, project_uuid
)
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="Profile size exceeds 10MB limit",
)
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]
elif field_name == "git_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()
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
.options(selectinload(ConfigProfile.includes))
)
return result.scalar_one()
async def update_includes(
session: AsyncSession,
profile: ConfigProfile,
included_ids: list[uuid.UUID],
user_id: uuid.UUID,
) -> ConfigProfile:
"""Replace profile includes after cycle check."""
for inc_uuid in included_ids:
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 != 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",
)
from src.services.config.config_profile_resolver import check_include_cycle
cycle = await check_include_cycle(session, profile.id, None)
if cycle is None and included_ids:
for inc_uuid in included_ids:
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}",
)
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()
for order_index, inc_uuid in enumerate(included_ids):
include = ConfigProfileInclude(
profile_id=profile.id,
included_profile_id=inc_uuid,
order_index=order_index,
)
session.add(include)
await session.flush()
await session.commit()
result = await session.execute(
select(ConfigProfile).where(ConfigProfile.id == profile.id)
)
return result.scalar_one()
+305 -3
View File
@@ -398,9 +398,6 @@ def expand_glob_source(source_path: str, repo_path: str) -> list[str]:
return results
router = APIRouter(prefix="/projects", tags=["tool-instances"])
async def validate_config_profile(
session: AsyncSession,
profile_id: str | None,
@@ -880,3 +877,308 @@ async def prepare_manifest_instance(
return image_tag, compose_content, manifest, home_dir
async def create_tool_instance(
session: AsyncSession,
user_id: uuid.UUID,
project_id: uuid.UUID,
repo_id: uuid.UUID,
data: "CreateInstanceRequest",
) -> ToolInstance:
"""Create a new tool instance for a repository.
Returns the created ToolInstance.
Raises ValueError for invalid input, RuntimeError for internal failures.
"""
repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id:
raise ValueError("repository not found")
tool_type_id = uuid.UUID(data.tool_type_id)
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
raise ValueError("tool type not found")
# Validate config profile if provided
selected_profile_id = await validate_config_profile(
session, data.config_profile_id, user_id, project_id, tool_type_id
)
# Resolve workspace if provided
workspace = None
workspace_id = None
if data.workspace_id:
from src.models import Workspace as WorkspaceModel
try:
workspace_id = uuid.UUID(data.workspace_id)
except ValueError:
raise ValueError("Invalid workspace_id format")
workspace = await session.get(WorkspaceModel, workspace_id)
if workspace is None:
raise ValueError("workspace not found")
if workspace.repo_id != repo_id:
raise ValueError("workspace does not belong to this repository")
# Validate clone mode requirements (legacy path)
if data.clone_mode == "clone" and not workspace:
if not repo.remote_url:
raise ValueError("repository does not have a remote URL for cloning")
if not repo.ssh_key_id:
raise ValueError("repository must have an SSH key assigned for clone mode")
# Generate unique name
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
# Auto-generate display name with scoped numbering.
if data.display_name:
instance_display = data.display_name
else:
scope_name = workspace.name if workspace else repo.name
auto_name = f"{scope_name} / {tool_type.display_name}"
if workspace:
count_query = (
select(ToolInstance)
.where(ToolInstance.workspace_id == workspace_id)
.where(ToolInstance.tool_type_id == tool_type_id)
.where(ToolInstance.owner_id == user_id)
)
else:
count_query = (
select(ToolInstance)
.where(ToolInstance.repository_id == repo_id)
.where(ToolInstance.tool_type_id == tool_type_id)
.where(ToolInstance.owner_id == user_id)
)
result = await session.execute(count_query)
existing_count = len(result.scalars().all())
if existing_count > 0:
instance_display = f"{auto_name} #{existing_count + 1}"
else:
instance_display = auto_name
# Create instance directory
instance_dir = ensure_instance_directory(instance_name)
compose_path = os.path.join(instance_dir, "docker-compose.yml")
# Find free port
tool_port = find_free_port()
# Determine repo path based on workspace or clone mode
if workspace:
repo_path = workspace.path
elif data.clone_mode == "clone":
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
if ssh_key is None:
raise ValueError("repository SSH key not found")
ssh_key_path = None
try:
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
ssh_key_path = os.path.join(ssh_dir, "id_ed25519")
clone_path = clone_repository(
remote_url=repo.remote_url,
ssh_key_path=ssh_key_path,
instance_dir=instance_dir,
branch=data.branch or "main",
)
repo_path = clone_path
except Exception as exc:
logger.exception("Failed to clone repository: %s", exc)
cleanup_ssh_key_files(instance_dir)
raise RuntimeError(f"Failed to clone repository: {exc}")
else:
repo_path = repo.path
# Verify cloned repo has files
if data.clone_mode == "clone" and repo_path:
try:
repo_contents = os.listdir(repo_path)
if not repo_contents or (
len(repo_contents) == 1 and repo_contents[0] == ".git"
):
logger.error("Cloned repository at %s appears empty", repo_path)
raise RuntimeError("Cloned repository is empty")
logger.debug(
"Verified cloned repo at %s has %d items",
repo_path,
len(repo_contents),
)
except Exception as exc:
logger.exception("Failed to verify cloned repository: %s", exc)
raise RuntimeError(f"Cloned repository verification failed: {exc}")
# Create new local branch if requested
if data.clone_mode == "clone" and data.new_branch:
try:
result = subprocess.run(
["git", "-C", repo_path, "checkout", "-b", data.new_branch],
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.error(
"Failed to create branch %s: %s", data.new_branch, result.stderr
)
raise RuntimeError(f"Failed to create branch: {result.stderr}")
logger.debug(
"Created local branch %s in cloned repository", data.new_branch
)
except Exception as exc:
logger.exception("Failed to create local branch: %s", exc)
raise RuntimeError(f"Failed to create local branch: {exc}")
# Handle based on definition type
if tool_type.definition_type == "dockerfile":
image_tag = f"headquarter/{instance_name}:latest".lower()
if tool_type.dockerfile_template:
returncode, stdout, stderr = await asyncio.to_thread(
build_image,
instance_dir=instance_dir,
dockerfile=tool_type.dockerfile_template,
tag=image_tag,
build_context=tool_type.build_context,
)
if returncode != 0:
logger.error(
"Failed to build image for instance %s: %s",
instance_name,
stderr,
)
raise RuntimeError(f"Failed to build Docker image: {stderr[:500]}")
logger.info(
"Successfully built image %s for instance %s",
image_tag,
instance_name,
)
ports_section = (
f""" ports:\n - "{tool_port}:{tool_type.default_port}"\n"""
if tool_type.default_port and tool_type.default_port > 0
else ""
)
compose_content = f"""version: "3.8"\nservices:\n app:\n image: {image_tag}\n container_name: {instance_name.lower()}\n stdin_open: true\n tty: true\n{ports_section} volumes:\n - {repo_path}:/workspace\n restart: unless-stopped\n"""
write_compose_file(instance_dir, compose_content)
elif tool_type.definition_type == "manifest":
from src.models import ToolDefinitionManifest
manifest_def = await session.get(
ToolDefinitionManifest, tool_type.manifest_id
)
if not manifest_def:
raise RuntimeError("Manifest definition not found for this tool type")
manifest = dict(manifest_def.manifest)
if manifest_def.base_definition_id:
base_def = await session.get(
ToolDefinitionManifest, manifest_def.base_definition_id
)
if base_def:
manifest = resolve_base(
deep_merge(dict(base_def.manifest), manifest)
)
image_tag = compute_image_tag(tool_type.name, manifest)
variables = {
"IMAGE_TAG": image_tag,
"INSTANCE_NAME": instance_name.lower(),
"INSTANCE_DIR": instance_dir,
"REPO_PATH": repo_path,
"SSH_PATH": "",
"TOOL_PORT": tool_port,
"EXTRA_ENV": {},
"EXTRA_VOLUMES": [],
}
compose_content = compile_compose(manifest, variables)
write_compose_file(instance_dir, compose_content)
else:
variables = {
"REPO_PATH": repo_path,
"INSTANCE_NAME": instance_name,
"INSTANCE_ID": instance_name,
"TOOL_NAME": instance_name,
"TOOL_PORT": tool_port,
"USER_ID": str(user_id),
"PROJECT_ID": str(project_id),
}
compose_content = render_compose_template(
tool_type.compose_template, variables
)
if data.clone_mode == "clone" and repo_path:
import yaml
compose_data = yaml.safe_load(compose_content)
repo_mounted = False
if compose_data and "services" in compose_data:
for svc in compose_data["services"].values():
volumes = svc.get("volumes", [])
for vol in volumes:
vol_str = str(vol)
if repo_path in vol_str:
repo_mounted = True
break
if repo_mounted:
break
if not repo_mounted:
logger.warning(
"Compose template for tool type %s does not mount repo path; adding default mount",
tool_type.name,
)
if compose_data and "services" in compose_data:
for svc in compose_data["services"].values():
if "volumes" not in svc:
svc["volumes"] = []
svc["volumes"].append(f"{repo_path}:/workspace")
break
compose_content = yaml.dump(
compose_data, default_flow_style=False
)
write_compose_file(instance_dir, compose_content)
# Create database record
instance = ToolInstance(
name=instance_name,
display_name=instance_display,
tool_type_id=tool_type_id,
repository_id=repo_id,
project_id=project_id,
owner_id=user_id,
status="pending",
compose_path=compose_path,
port=tool_port,
workspace_id=workspace_id,
clone_mode=data.clone_mode,
branch=data.new_branch
if data.new_branch
else (data.branch if data.clone_mode == "clone" else None),
selected_config_profile_id=selected_profile_id,
ssh_key_ids=data.ssh_key_ids or None,
)
session.add(instance)
await session.commit()
await session.refresh(instance)
await publish_lifecycle_event(
event_bus=_event_bus,
session=session,
instance=instance,
event_type="instance.created",
created_by=user_id,
status="pending",
message="Instance created",
)
return instance