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:
@@ -21,19 +21,17 @@ from src.schemas.config import (
|
|||||||
)
|
)
|
||||||
from src.services.config.config_profile_resolver import (
|
from src.services.config.config_profile_resolver import (
|
||||||
ConfigProfileCycleError,
|
ConfigProfileCycleError,
|
||||||
check_include_cycle,
|
|
||||||
resolve_profile,
|
resolve_profile,
|
||||||
resolved_profile_to_dict,
|
resolved_profile_to_dict,
|
||||||
)
|
)
|
||||||
from src.services.config.crud_service import (
|
from src.services.config.crud_service import (
|
||||||
calculate_profile_size,
|
create_profile,
|
||||||
check_access,
|
|
||||||
get_or_create_user_config,
|
get_or_create_user_config,
|
||||||
get_profile_with_includes,
|
get_profile_with_includes,
|
||||||
profile_to_response,
|
profile_to_response,
|
||||||
|
update_includes,
|
||||||
|
update_profile,
|
||||||
validate_default_profiles,
|
validate_default_profiles,
|
||||||
validate_git_mounts,
|
|
||||||
MAX_PROFILE_SIZE_BYTES,
|
|
||||||
)
|
)
|
||||||
from src.services.config.resolver_service import (
|
from src.services.config.resolver_service import (
|
||||||
resolve_default_profile,
|
resolve_default_profile,
|
||||||
@@ -99,63 +97,8 @@ async def create_config_profile(
|
|||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""Create a new config profile."""
|
"""Create a new config profile."""
|
||||||
user_uuid = current_user_id
|
profile = await create_profile(session, current_user_id, data)
|
||||||
|
logger.debug("Created config profile %s for user %s", profile.id, 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)
|
|
||||||
return profile_to_response(profile)
|
return profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@@ -196,70 +139,7 @@ async def update_config_profile(
|
|||||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
)
|
)
|
||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
profile = await update_profile(session, profile, data)
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
logger.debug("Updated config profile %s", profile.id)
|
logger.debug("Updated config profile %s", profile.id)
|
||||||
return profile_to_response(profile)
|
return profile_to_response(profile)
|
||||||
|
|
||||||
@@ -289,7 +169,7 @@ async def delete_config_profile(
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
|
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
|
||||||
async def update_profile_includes(
|
async def update_profile_includes_endpoint(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
data: ConfigProfileIncludeUpdate,
|
data: ConfigProfileIncludeUpdate,
|
||||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
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]
|
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
||||||
for inc_uuid in included_uuids:
|
profile = await update_includes(session, profile, included_uuids, current_user_id)
|
||||||
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()
|
|
||||||
|
|
||||||
inc_result = await session.execute(
|
inc_result = await session.execute(
|
||||||
select(ConfigProfileInclude).where(
|
select(ConfigProfileInclude).where(
|
||||||
|
|||||||
@@ -155,3 +155,201 @@ async def validate_default_profiles(
|
|||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=f"Profile does not belong to user: {profile_id_str}",
|
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()
|
||||||
|
|||||||
@@ -398,9 +398,6 @@ def expand_glob_source(source_path: str, repo_path: str) -> list[str]:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_config_profile(
|
async def validate_config_profile(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
profile_id: str | None,
|
profile_id: str | None,
|
||||||
@@ -880,3 +877,308 @@ async def prepare_manifest_instance(
|
|||||||
return image_tag, compose_content, manifest, home_dir
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user