From 6efe5249741bb7b30bbafc4871b715895bb98fdb Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 5 Jun 2026 20:56:50 +0000 Subject: [PATCH] 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 --- apps/api/src/api/config/config_profiles.py | 190 +---------- apps/api/src/services/config/crud_service.py | 198 +++++++++++ .../api/src/services/tool/instance_service.py | 308 +++++++++++++++++- 3 files changed, 511 insertions(+), 185 deletions(-) diff --git a/apps/api/src/api/config/config_profiles.py b/apps/api/src/api/config/config_profiles.py index 4a311a0..ce39976 100644 --- a/apps/api/src/api/config/config_profiles.py +++ b/apps/api/src/api/config/config_profiles.py @@ -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( diff --git a/apps/api/src/services/config/crud_service.py b/apps/api/src/services/config/crud_service.py index da56068..808027f 100644 --- a/apps/api/src/services/config/crud_service.py +++ b/apps/api/src/services/config/crud_service.py @@ -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() diff --git a/apps/api/src/services/tool/instance_service.py b/apps/api/src/services/tool/instance_service.py index c5f6f20..bea3e35 100644 --- a/apps/api/src/services/tool/instance_service.py +++ b/apps/api/src/services/tool/instance_service.py @@ -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