Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
"""add_config_profiles
|
||||
|
||||
Revision ID: 2026_05_24_add_config_profiles
|
||||
Revises: f3d2dc90ba3a
|
||||
Create Date: 2026-05-24 14:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_24_add_config_profiles"
|
||||
down_revision: Union[str, Sequence[str], None] = "f3d2dc90ba3a"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create config_profiles table
|
||||
op.create_table(
|
||||
"config_profiles",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("project_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("env_vars", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
|
||||
sa.Column("runtime_hints", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
|
||||
sa.Column("mounts", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="[]"),
|
||||
sa.Column("files", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
|
||||
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
||||
)
|
||||
|
||||
# Create indexes for config_profiles
|
||||
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
|
||||
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
|
||||
op.create_index("idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"])
|
||||
|
||||
# Create config_profile_includes table
|
||||
op.create_table(
|
||||
"config_profile_includes",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||
sa.Column("profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_profile_includes"),
|
||||
)
|
||||
|
||||
# Create indexes for config_profile_includes
|
||||
op.create_index("idx_config_profile_includes_profile", "config_profile_includes", ["profile_id"])
|
||||
op.create_index("idx_config_profile_includes_included", "config_profile_includes", ["included_profile_id"])
|
||||
|
||||
# Add selected_config_profile_id to tool_instances
|
||||
op.add_column(
|
||||
"tool_instances",
|
||||
sa.Column("selected_config_profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True),
|
||||
)
|
||||
op.create_index("idx_tool_instances_config_profile", "tool_instances", ["selected_config_profile_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove selected_config_profile_id from tool_instances
|
||||
op.drop_index("idx_tool_instances_config_profile", table_name="tool_instances")
|
||||
op.drop_column("tool_instances", "selected_config_profile_id")
|
||||
|
||||
# Drop config_profile_includes table
|
||||
op.drop_index("idx_config_profile_includes_included", table_name="config_profile_includes")
|
||||
op.drop_index("idx_config_profile_includes_profile", table_name="config_profile_includes")
|
||||
op.drop_table("config_profile_includes")
|
||||
|
||||
# Drop config_profiles table
|
||||
op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles")
|
||||
op.drop_index("idx_config_profiles_project", table_name="config_profiles")
|
||||
op.drop_index("idx_config_profiles_user", table_name="config_profiles")
|
||||
op.drop_table("config_profiles")
|
||||
@@ -0,0 +1,635 @@
|
||||
"""Config profile API endpoints."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
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.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
from src.models.project import Project
|
||||
from src.models.tool_type import ToolType
|
||||
from src.services.config_profile_resolver import (
|
||||
ConfigProfileCycleError,
|
||||
check_include_cycle,
|
||||
resolve_profile,
|
||||
resolved_profile_to_dict,
|
||||
)
|
||||
|
||||
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 _validate_uuid(v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
try:
|
||||
uuid.UUID(v)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid UUID: {v}")
|
||||
return v
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class MountItem(BaseModel):
|
||||
target: str = Field(description="Absolute mount target path")
|
||||
mode: str = Field(default="rw", description="Mount mode: ro or rw")
|
||||
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
|
||||
|
||||
@field_validator("target")
|
||||
@classmethod
|
||||
def validate_target(cls, v: str) -> str:
|
||||
if not v.startswith("/"):
|
||||
raise ValueError("Mount target must be absolute (start with /)")
|
||||
return v
|
||||
|
||||
@field_validator("mode")
|
||||
@classmethod
|
||||
def validate_mode(cls, v: str) -> str:
|
||||
if v not in ("ro", "rw"):
|
||||
raise ValueError("Mount mode must be 'ro' or 'rw'")
|
||||
return v
|
||||
|
||||
@field_validator("files")
|
||||
@classmethod
|
||||
def validate_files(cls, v: dict) -> dict:
|
||||
for path in v.keys():
|
||||
if ".." in path or path.startswith("/") or not path:
|
||||
raise ValueError(f"Invalid file path: {path}")
|
||||
return v
|
||||
|
||||
|
||||
class ConfigProfileCreate(BaseModel):
|
||||
name: str = Field(description="Profile name (unique per user)")
|
||||
description: str | None = Field(default=None, description="Optional description")
|
||||
project_id: str | None = Field(default=None, description="Optional project ID")
|
||||
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
||||
env_vars: dict = Field(default_factory=dict, description="Environment variables")
|
||||
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
|
||||
mounts: list[MountItem] = Field(default_factory=list, description="Mount definitions")
|
||||
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
|
||||
is_default: bool = Field(default=False, description="Whether this is the default profile for its scope")
|
||||
|
||||
@field_validator("project_id", "tool_type_id")
|
||||
@classmethod
|
||||
def validate_uuids(cls, v: str | None) -> str | None:
|
||||
return _validate_uuid(v)
|
||||
|
||||
@field_validator("files")
|
||||
@classmethod
|
||||
def validate_files(cls, v: dict) -> dict:
|
||||
for path in v.keys():
|
||||
if ".." in path or path.startswith("/") or not path:
|
||||
raise ValueError(f"Invalid file path: {path}")
|
||||
return v
|
||||
|
||||
@field_validator("env_vars")
|
||||
@classmethod
|
||||
def validate_env_vars(cls, v: dict) -> dict:
|
||||
if not isinstance(v, dict):
|
||||
raise ValueError("env_vars must be a JSON object")
|
||||
return v
|
||||
|
||||
@field_validator("runtime_hints")
|
||||
@classmethod
|
||||
def validate_runtime_hints(cls, v: dict) -> dict:
|
||||
if not isinstance(v, dict):
|
||||
raise ValueError("runtime_hints must be a JSON object")
|
||||
return v
|
||||
|
||||
@field_validator("mounts")
|
||||
@classmethod
|
||||
def validate_mounts(cls, v: list) -> list:
|
||||
if not isinstance(v, list):
|
||||
raise ValueError("mounts must be a JSON array")
|
||||
return v
|
||||
|
||||
|
||||
class ConfigProfileUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, description="Profile name")
|
||||
description: str | None = Field(default=None, description="Optional description")
|
||||
project_id: str | None = Field(default=None, description="Optional project ID")
|
||||
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
||||
env_vars: dict | None = Field(default=None, description="Environment variables")
|
||||
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
|
||||
mounts: list[MountItem] | None = Field(default=None, description="Mount definitions")
|
||||
files: dict | None = Field(default=None, description="Files as {relative_path: content}")
|
||||
is_default: bool | None = Field(default=None, description="Whether this is the default profile")
|
||||
|
||||
@field_validator("project_id", "tool_type_id")
|
||||
@classmethod
|
||||
def validate_uuids(cls, v: str | None) -> str | None:
|
||||
return _validate_uuid(v)
|
||||
|
||||
@field_validator("files")
|
||||
@classmethod
|
||||
def validate_files(cls, v: dict | None) -> dict | None:
|
||||
if v is None:
|
||||
return v
|
||||
for path in v.keys():
|
||||
if ".." in path or path.startswith("/") or not path:
|
||||
raise ValueError(f"Invalid file path: {path}")
|
||||
return v
|
||||
|
||||
|
||||
class ConfigProfileIncludeUpdate(BaseModel):
|
||||
includes: list[str] = Field(description="Ordered list of included profile IDs")
|
||||
|
||||
@field_validator("includes")
|
||||
@classmethod
|
||||
def validate_includes(cls, v: list) -> list:
|
||||
for item in v:
|
||||
try:
|
||||
uuid.UUID(item)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid UUID in includes: {item}")
|
||||
return v
|
||||
|
||||
|
||||
class ConfigProfileResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
description: str | None
|
||||
project_id: str | None
|
||||
tool_type_id: str | None
|
||||
env_vars: dict
|
||||
runtime_hints: dict
|
||||
mounts: list
|
||||
files: dict
|
||||
is_default: bool
|
||||
includes: list[dict]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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 [],
|
||||
"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(
|
||||
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
||||
tool_type_id: str | None = Query(None, description="Filter by tool type compatibility"),
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""List config profiles, optionally filtered by compatibility."""
|
||||
user_uuid = current_user_id
|
||||
query = select(ConfigProfile).where(ConfigProfile.user_id == user_uuid).options(selectinload(ConfigProfile.includes))
|
||||
|
||||
if project_id or tool_type_id:
|
||||
# Compatibility filter: include portable profiles and matching scoped profiles
|
||||
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)
|
||||
)
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
result = await session.execute(query)
|
||||
profiles = result.scalars().all()
|
||||
return [_profile_to_response(p) for p in profiles]
|
||||
|
||||
|
||||
@router.post("", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_config_profile(
|
||||
data: ConfigProfileCreate,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Create a new config profile."""
|
||||
user_uuid = current_user_id
|
||||
|
||||
# Check for duplicate name
|
||||
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",
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
# Check size
|
||||
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",
|
||||
)
|
||||
|
||||
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],
|
||||
files=data.files,
|
||||
is_default=data.is_default,
|
||||
)
|
||||
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)
|
||||
.options(selectinload(ConfigProfile.includes))
|
||||
)
|
||||
profile = result.scalar_one()
|
||||
|
||||
logger.info("Created config profile %s for user %s", profile.id, user_uuid)
|
||||
return _profile_to_response(profile)
|
||||
|
||||
|
||||
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
|
||||
async def get_config_profile(
|
||||
profile_id: str,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Get a config profile by 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")
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||
return _profile_to_response(profile)
|
||||
|
||||
|
||||
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
|
||||
async def update_config_profile(
|
||||
profile_id: str,
|
||||
data: ConfigProfileUpdate,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Update a config profile."""
|
||||
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")
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# Handle name uniqueness
|
||||
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",
|
||||
)
|
||||
|
||||
# Validate references
|
||||
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)
|
||||
|
||||
# Check size
|
||||
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=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB 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
|
||||
elif field_name == "mounts" and value is not None:
|
||||
value = [m.model_dump() for m in value]
|
||||
setattr(profile, field_name, value)
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Re-fetch with includes to avoid lazy loading issues
|
||||
result = await session.execute(
|
||||
select(ConfigProfile)
|
||||
.where(ConfigProfile.id == profile.id)
|
||||
.options(selectinload(ConfigProfile.includes))
|
||||
)
|
||||
profile = result.scalar_one()
|
||||
|
||||
logger.info("Updated config profile %s", profile.id)
|
||||
return _profile_to_response(profile)
|
||||
|
||||
|
||||
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_config_profile(
|
||||
profile_id: str,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Delete a config profile."""
|
||||
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")
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||
|
||||
await session.delete(profile)
|
||||
await session.commit()
|
||||
|
||||
logger.info("Deleted config profile %s", profile_id)
|
||||
return None
|
||||
|
||||
|
||||
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
|
||||
async def update_profile_includes(
|
||||
profile_id: str,
|
||||
data: ConfigProfileIncludeUpdate,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
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))
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(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)
|
||||
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",
|
||||
)
|
||||
|
||||
# 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:
|
||||
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}",
|
||||
)
|
||||
|
||||
# Remove existing includes
|
||||
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()
|
||||
|
||||
# Add new includes
|
||||
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()
|
||||
|
||||
# Re-fetch profile (includes loaded separately due to SQLite async issue)
|
||||
result = await session.execute(
|
||||
select(ConfigProfile).where(ConfigProfile.id == profile.id)
|
||||
)
|
||||
profile = result.scalar_one()
|
||||
|
||||
inc_result = await session.execute(
|
||||
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
|
||||
)
|
||||
direct_includes = inc_result.scalars().all()
|
||||
|
||||
logger.info("Updated includes for config profile %s", profile.id)
|
||||
return _profile_to_response(profile, list(direct_includes))
|
||||
|
||||
|
||||
@router.get("/{profile_id}/preview")
|
||||
async def preview_config_profile(
|
||||
profile_id: str,
|
||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
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))
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||
if profile.user_id != current_user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||
|
||||
try:
|
||||
resolved = await resolve_profile(session, profile.id)
|
||||
except ConfigProfileCycleError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
return resolved_profile_to_dict(resolved)
|
||||
|
||||
|
||||
@router.get("/defaults/resolve")
|
||||
async def resolve_default_profile(
|
||||
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)
|
||||
)
|
||||
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}
|
||||
@@ -20,11 +20,11 @@ from src.auth.dependencies import get_db_session
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.config_profile import ConfigProfile
|
||||
from src.models.tool_config import ToolConfig
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.models.config_folder import ConfigFolder
|
||||
from src.services.docker import (
|
||||
check_tunnel_health,
|
||||
connect_container_to_network,
|
||||
@@ -43,10 +43,14 @@ from src.services.docker import (
|
||||
write_compose_file,
|
||||
write_config_files,
|
||||
write_env_file,
|
||||
write_config_folder_files,
|
||||
)
|
||||
from src.services.clone import check_dirty_state, clone_repository, remove_clone_directory
|
||||
from src.services.docker_build import build_image
|
||||
from src.services.config_profile_resolver import (
|
||||
ConfigProfileCycleError,
|
||||
apply_resolved_profile,
|
||||
resolve_profile,
|
||||
)
|
||||
from src.services.readiness_probe import execute_probe
|
||||
from src.services.ssh_keys import prepare_ssh_key_files, cleanup_ssh_key_files
|
||||
|
||||
@@ -63,6 +67,78 @@ class CreateInstanceRequest(BaseModel):
|
||||
clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'")
|
||||
branch: str | None = Field(default="main", description="Branch to clone (when clone_mode='clone')")
|
||||
new_branch: str | None = Field(default=None, description="Create a new local branch after cloning")
|
||||
config_profile_id: str | None = Field(default=None, description="Optional config profile ID for launch")
|
||||
|
||||
|
||||
class StartInstanceRequest(BaseModel):
|
||||
"""Request body for starting a tool instance."""
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
config_profile_id: str | None = Field(default=None, description="Config profile ID to apply, or null for none")
|
||||
|
||||
|
||||
async def _validate_config_profile(
|
||||
session: AsyncSession,
|
||||
profile_id: str | None,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
tool_type_id: uuid.UUID,
|
||||
) -> uuid.UUID | None:
|
||||
"""Validate a config profile selection.
|
||||
|
||||
Args:
|
||||
session: Database session.
|
||||
profile_id: Profile ID string or None.
|
||||
user_id: Authenticated user ID.
|
||||
project_id: Project ID for compatibility check.
|
||||
tool_type_id: Tool type ID for compatibility check.
|
||||
|
||||
Returns:
|
||||
Validated UUID or None.
|
||||
|
||||
Raises:
|
||||
HTTPException: If profile is not found, not owned, or incompatible.
|
||||
"""
|
||||
if profile_id is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
profile_uuid = uuid.UUID(profile_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid config profile ID: {profile_id}",
|
||||
)
|
||||
|
||||
profile = await session.get(ConfigProfile, profile_uuid)
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Config profile not found: {profile_id}",
|
||||
)
|
||||
|
||||
if profile.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to use this config profile",
|
||||
)
|
||||
|
||||
# Check compatibility: profile must be portable or match project/tool
|
||||
is_compatible = (
|
||||
(profile.project_id is None and profile.tool_type_id is None)
|
||||
or (profile.project_id == project_id)
|
||||
or (profile.tool_type_id == tool_type_id)
|
||||
or (profile.project_id == project_id and profile.tool_type_id == tool_type_id)
|
||||
)
|
||||
|
||||
if not is_compatible:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Selected config profile is not compatible with this project and tool type",
|
||||
)
|
||||
|
||||
return profile_uuid
|
||||
|
||||
|
||||
def _modify_compose_file(
|
||||
@@ -199,6 +275,11 @@ async def create_instance(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="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
|
||||
)
|
||||
|
||||
try:
|
||||
# Validate clone mode requirements
|
||||
if data.clone_mode == "clone":
|
||||
@@ -386,6 +467,7 @@ services:
|
||||
port=tool_port,
|
||||
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,
|
||||
)
|
||||
session.add(instance)
|
||||
await session.commit()
|
||||
@@ -399,6 +481,7 @@ services:
|
||||
"status": instance.status,
|
||||
"clone_mode": instance.clone_mode,
|
||||
"branch": instance.branch,
|
||||
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
|
||||
"created_at": instance.created_at.isoformat(),
|
||||
}
|
||||
except Exception as exc:
|
||||
@@ -525,6 +608,7 @@ async def get_instance(
|
||||
"port": instance.port,
|
||||
"clone_mode": instance.clone_mode,
|
||||
"branch": instance.branch,
|
||||
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
|
||||
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
|
||||
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
|
||||
"created_at": instance.created_at.isoformat(),
|
||||
@@ -540,6 +624,7 @@ async def start_instance(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
data: StartInstanceRequest | None = None,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
@@ -549,6 +634,7 @@ async def start_instance(
|
||||
project_id: UUID of the project.
|
||||
repo_id: UUID of the repository.
|
||||
instance_id: UUID of the instance to start.
|
||||
data: Optional start configuration including config profile selection.
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
@@ -564,6 +650,14 @@ async def start_instance(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||
)
|
||||
|
||||
# Validate and store config profile selection
|
||||
if data and data.config_profile_id is not None:
|
||||
selected_profile_id = await _validate_config_profile(
|
||||
session, data.config_profile_id, user_id, project_id, instance.tool_type_id
|
||||
)
|
||||
instance.selected_config_profile_id = selected_profile_id
|
||||
await session.commit()
|
||||
|
||||
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found"
|
||||
@@ -614,17 +708,45 @@ async def start_instance(
|
||||
# Merge extra env vars
|
||||
env_vars.update(extra_env_vars)
|
||||
|
||||
# Fetch active config folders for this user
|
||||
folder_query = select(ConfigFolder).where(
|
||||
ConfigFolder.user_id == user_id,
|
||||
ConfigFolder.is_active == True,
|
||||
)
|
||||
folder_result = await session.execute(folder_query)
|
||||
config_folders = folder_result.scalars().all()
|
||||
logger.info("Found %d active config folders for instance %s", len(config_folders), instance.id)
|
||||
# Apply selected config profile if any
|
||||
instance_dir = os.path.dirname(instance.compose_path)
|
||||
if instance.selected_config_profile_id is not None:
|
||||
try:
|
||||
resolved = await resolve_profile(session, instance.selected_config_profile_id)
|
||||
profile_env, profile_files, profile_mounts, profile_hints = apply_resolved_profile(
|
||||
instance_dir, resolved
|
||||
)
|
||||
# Profile env vars override tool config env vars
|
||||
env_vars.update(profile_env)
|
||||
# Profile files are written by apply_resolved_profile
|
||||
config_files.update(profile_files)
|
||||
# Profile mounts are added to extra volumes
|
||||
extra_volumes.extend(profile_mounts)
|
||||
# Profile runtime hints override tool config values
|
||||
if profile_hints.get("start_command"):
|
||||
start_command = profile_hints["start_command"]
|
||||
if profile_hints.get("working_directory"):
|
||||
working_directory = profile_hints["working_directory"]
|
||||
if profile_hints.get("port_override"):
|
||||
port_override = profile_hints["port_override"]
|
||||
logger.info(
|
||||
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d)",
|
||||
resolved.profile_name,
|
||||
instance.id,
|
||||
len(profile_env),
|
||||
len(profile_files),
|
||||
len(profile_mounts),
|
||||
)
|
||||
except ConfigProfileCycleError as exc:
|
||||
logger.error("Cycle detected in config profile for instance %s: %s", instance.id, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Config profile cycle detected: {exc}",
|
||||
)
|
||||
else:
|
||||
logger.info("No config profile selected for instance %s", instance.id)
|
||||
|
||||
# Write env file and config files
|
||||
instance_dir = os.path.dirname(instance.compose_path)
|
||||
env_file_path = None
|
||||
|
||||
if env_vars:
|
||||
@@ -635,12 +757,6 @@ async def start_instance(
|
||||
write_config_files(instance_dir, config_files)
|
||||
logger.info("Wrote %d config files for instance %s", len(config_files), instance.id)
|
||||
|
||||
# Write config folder files
|
||||
if config_folders:
|
||||
folder_volumes = write_config_folder_files(instance_dir, config_folders, str(project_id))
|
||||
extra_volumes.extend(folder_volumes)
|
||||
logger.info("Wrote config folders with %d volume mounts for instance %s", len(folder_volumes), instance.id)
|
||||
|
||||
# Mount SSH key for clone-mode instances
|
||||
if instance.clone_mode == "clone":
|
||||
repo = await session.get(GitRepository, instance.repository_id)
|
||||
@@ -958,7 +1074,30 @@ async def restart_instance(
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to stop old tunnel for instance %s: %s", instance.id, exc)
|
||||
|
||||
# Re-apply stored config profile on restart
|
||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||
instance_dir = os.path.dirname(instance.compose_path)
|
||||
if instance.selected_config_profile_id is not None:
|
||||
try:
|
||||
resolved = await resolve_profile(session, instance.selected_config_profile_id)
|
||||
profile_env, profile_files, profile_mounts, profile_hints = apply_resolved_profile(
|
||||
instance_dir, resolved
|
||||
)
|
||||
# Write env file with resolved profile env vars
|
||||
if profile_env:
|
||||
write_env_file(instance_dir, profile_env)
|
||||
logger.info(
|
||||
"Re-applied config profile %s on restart for instance %s",
|
||||
resolved.profile_name,
|
||||
instance.id,
|
||||
)
|
||||
except ConfigProfileCycleError as exc:
|
||||
logger.error(
|
||||
"Cycle detected in stored config profile for instance %s: %s",
|
||||
instance.id,
|
||||
exc,
|
||||
)
|
||||
|
||||
returncode, stdout, stderr = execute_compose_command(
|
||||
instance.compose_path, "restart"
|
||||
)
|
||||
@@ -1483,6 +1622,7 @@ async def get_user_sessions(
|
||||
"url": instance.url,
|
||||
"clone_mode": instance.clone_mode,
|
||||
"branch": instance.branch,
|
||||
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
|
||||
"created_at": instance.created_at.isoformat() if instance.created_at else None,
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import hmac
|
||||
import hashlib
|
||||
import json
|
||||
import base64
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from src.config import Settings
|
||||
@@ -23,7 +23,7 @@ def create_session_cookie(*, settings: Settings, user_id: str) -> str:
|
||||
"""Create a signed session cookie value."""
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"exp": int((datetime.now(UTC) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
|
||||
"exp": int((datetime.now(timezone.utc) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
|
||||
}
|
||||
|
||||
header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode())
|
||||
@@ -65,7 +65,7 @@ def decode_session_cookie(*, settings: Settings, cookie_value: str) -> dict[str,
|
||||
payload = json.loads(payload_bytes)
|
||||
|
||||
# Check expiry
|
||||
if payload.get("exp", 0) < int(datetime.now(UTC).timestamp()):
|
||||
if payload.get("exp", 0) < int(datetime.now(timezone.utc).timestamp()):
|
||||
raise ValueError("session expired")
|
||||
|
||||
return payload
|
||||
|
||||
@@ -18,6 +18,7 @@ from src.api.ssh_keys import router as ssh_keys_router
|
||||
from src.api.terminal import router as terminal_router
|
||||
from src.api.instance_proxy import router as instance_proxy_router
|
||||
from src.api.config_folders import router as config_folders_router
|
||||
from src.api.config_profiles import router as config_profiles_router
|
||||
from src.api.tool_configs import router as tool_configs_router
|
||||
from src.api.tool_instances import router as tool_instances_router
|
||||
from src.api.tool_instances import sessions_router
|
||||
@@ -125,6 +126,7 @@ app.include_router(git_repositories_router)
|
||||
app.include_router(user_config_router)
|
||||
app.include_router(tool_types_router)
|
||||
app.include_router(config_folders_router)
|
||||
app.include_router(config_profiles_router)
|
||||
app.include_router(tool_instances_router)
|
||||
app.include_router(tool_configs_router)
|
||||
app.include_router(sessions_router)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from src.models.base import Base
|
||||
from src.models.config_folder import ConfigFolder
|
||||
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
@@ -8,4 +9,4 @@ from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
__all__ = ["Base", "ConfigFolder", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
||||
__all__ = ["Base", "ConfigFolder", "ConfigProfile", "ConfigProfileInclude", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean
|
||||
from sqlalchemy import Uuid as UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.project import Project
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "config_profiles"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
project_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
env_vars: Mapped[dict] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
) # {"VAR_NAME": "value", ...}
|
||||
runtime_hints: Mapped[dict] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
) # {"start_command": "...", "working_dir": "...", ...}
|
||||
mounts: Mapped[list] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
) # [{"target": "/path", "mode": "rw", "files": {"rel/path": "content"}}, ...]
|
||||
files: Mapped[dict] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
) # {"rel/path": "content", ...}
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
user: Mapped["User"] = relationship()
|
||||
project: Mapped["Project | None"] = relationship()
|
||||
tool_type: Mapped["ToolType | None"] = relationship()
|
||||
includes: Mapped[list["ConfigProfileInclude"]] = relationship(
|
||||
"ConfigProfileInclude",
|
||||
foreign_keys="ConfigProfileInclude.profile_id",
|
||||
order_by="ConfigProfileInclude.order_index",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class ConfigProfileInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "config_profile_includes"
|
||||
|
||||
profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
included_profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
profile: Mapped["ConfigProfile"] = relationship(
|
||||
"ConfigProfile",
|
||||
foreign_keys=[profile_id],
|
||||
back_populates="includes",
|
||||
)
|
||||
included_profile: Mapped["ConfigProfile"] = relationship(
|
||||
"ConfigProfile",
|
||||
foreign_keys=[included_profile_id],
|
||||
)
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.config_profile import ConfigProfile
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.tool_type import ToolType
|
||||
@@ -71,8 +72,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
branch: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, default="main"
|
||||
)
|
||||
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
tool_type: Mapped["ToolType"] = relationship()
|
||||
repository: Mapped["GitRepository"] = relationship()
|
||||
project: Mapped["Project"] = relationship()
|
||||
owner: Mapped["User"] = relationship()
|
||||
selected_config_profile: Mapped["ConfigProfile | None"] = relationship()
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
"""Config profile resolver service.
|
||||
|
||||
Provides recursive ordered include resolution with deterministic merge rules
|
||||
and cycle protection.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigProfileCycleError(Exception):
|
||||
"""Raised when a cycle is detected in profile includes."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ConfigProfileNotFoundError(Exception):
|
||||
"""Raised when a referenced profile is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedMount:
|
||||
"""A resolved mount with merged files and final mode."""
|
||||
|
||||
target: str
|
||||
mode: str
|
||||
files: dict[str, str] = field(default_factory=dict)
|
||||
overridden_files: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedProfile:
|
||||
"""The fully resolved output of a config profile."""
|
||||
|
||||
profile_id: uuid.UUID
|
||||
profile_name: str
|
||||
env_vars: dict[str, str] = field(default_factory=dict)
|
||||
runtime_hints: dict[str, Any] = field(default_factory=dict)
|
||||
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
|
||||
files: dict[str, str] = field(default_factory=dict)
|
||||
env_overrides: dict[str, str] = field(default_factory=dict)
|
||||
hint_overrides: dict[str, str] = field(default_factory=dict)
|
||||
file_overrides: dict[str, str] = field(default_factory=dict)
|
||||
mount_overrides: dict[str, str] = field(default_factory=dict)
|
||||
included_profiles: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
def _detect_cycle(profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]) -> bool:
|
||||
"""Detect if adding profile_id to path would create a cycle.
|
||||
|
||||
Args:
|
||||
profile_id: The profile ID to check.
|
||||
visited: Set of already-visited profile IDs in current resolution.
|
||||
path: Current resolution path for error reporting.
|
||||
|
||||
Returns:
|
||||
True if a cycle would be created.
|
||||
"""
|
||||
if profile_id in visited:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _merge_env_vars(
|
||||
base: dict[str, str],
|
||||
overlay: dict[str, str],
|
||||
overrides: dict[str, str],
|
||||
source_name: str,
|
||||
) -> dict[str, str]:
|
||||
"""Merge env vars, tracking overrides.
|
||||
|
||||
Later values replace earlier values.
|
||||
"""
|
||||
result = dict(base)
|
||||
for key, value in overlay.items():
|
||||
if key in result and result[key] != value:
|
||||
overrides[key] = source_name
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _merge_runtime_hints(
|
||||
base: dict[str, Any],
|
||||
overlay: dict[str, Any],
|
||||
overrides: dict[str, str],
|
||||
source_name: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Merge runtime hints, tracking overrides.
|
||||
|
||||
Later values replace earlier values.
|
||||
"""
|
||||
result = dict(base)
|
||||
for key, value in overlay.items():
|
||||
if key in result and result[key] != value:
|
||||
overrides[key] = source_name
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _merge_files(
|
||||
base: dict[str, str],
|
||||
overlay: dict[str, str],
|
||||
overrides: dict[str, str],
|
||||
source_name: str,
|
||||
) -> dict[str, str]:
|
||||
"""Merge file maps, tracking overrides.
|
||||
|
||||
Later relative file paths win.
|
||||
"""
|
||||
result = dict(base)
|
||||
for path, content in overlay.items():
|
||||
if path in result and result[path] != content:
|
||||
overrides[path] = source_name
|
||||
result[path] = content
|
||||
return result
|
||||
|
||||
|
||||
def _merge_mounts(
|
||||
base: dict[str, ResolvedMount],
|
||||
overlay: list[dict[str, Any]],
|
||||
overrides: dict[str, str],
|
||||
source_name: str,
|
||||
) -> dict[str, ResolvedMount]:
|
||||
"""Merge mounts, tracking overrides.
|
||||
|
||||
Mounts with the same target path have their file maps merged and later
|
||||
relative file paths win. Mode conflicts: later layer wins.
|
||||
"""
|
||||
result = dict(base)
|
||||
for mount_data in overlay:
|
||||
target = mount_data["target"]
|
||||
mode = mount_data.get("mode", "rw")
|
||||
files = mount_data.get("files", {})
|
||||
|
||||
if target in result:
|
||||
existing = result[target]
|
||||
merged_files = dict(existing.files)
|
||||
file_overrides = dict(existing.overridden_files)
|
||||
for rel_path, content in files.items():
|
||||
if rel_path in merged_files and merged_files[rel_path] != content:
|
||||
file_overrides[rel_path] = source_name
|
||||
merged_files[rel_path] = content
|
||||
if existing.mode != mode:
|
||||
overrides[target] = source_name
|
||||
result[target] = ResolvedMount(
|
||||
target=target,
|
||||
mode=mode,
|
||||
files=merged_files,
|
||||
overridden_files=file_overrides,
|
||||
)
|
||||
else:
|
||||
result[target] = ResolvedMount(
|
||||
target=target,
|
||||
mode=mode,
|
||||
files=dict(files),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def _resolve_profile_recursive(
|
||||
session: AsyncSession,
|
||||
profile_id: uuid.UUID,
|
||||
visited: set[uuid.UUID],
|
||||
path: list[uuid.UUID],
|
||||
) -> ResolvedProfile:
|
||||
"""Recursively resolve a profile and its includes.
|
||||
|
||||
Args:
|
||||
session: Database session.
|
||||
profile_id: Profile ID to resolve.
|
||||
visited: Set of already-visited profile IDs in current resolution chain.
|
||||
path: Current resolution path for error reporting.
|
||||
|
||||
Returns:
|
||||
ResolvedProfile with all includes merged.
|
||||
|
||||
Raises:
|
||||
ConfigProfileCycleError: If a cycle is detected.
|
||||
ConfigProfileNotFoundError: If the profile is not found.
|
||||
"""
|
||||
if _detect_cycle(profile_id, visited, path):
|
||||
cycle_path = " -> ".join(str(p) for p in path + [profile_id])
|
||||
raise ConfigProfileCycleError(f"Cycle detected in profile includes: {cycle_path}")
|
||||
|
||||
profile = await session.get(ConfigProfile, profile_id)
|
||||
if profile is None:
|
||||
raise ConfigProfileNotFoundError(f"Config profile not found: {profile_id}")
|
||||
|
||||
new_visited = visited | {profile_id}
|
||||
new_path = path + [profile_id]
|
||||
|
||||
result = ResolvedProfile(
|
||||
profile_id=profile.id,
|
||||
profile_name=profile.name,
|
||||
)
|
||||
|
||||
# Resolve includes in order
|
||||
include_query = (
|
||||
select(ConfigProfileInclude)
|
||||
.where(ConfigProfileInclude.profile_id == profile_id)
|
||||
.order_by(ConfigProfileInclude.order_index)
|
||||
)
|
||||
include_result = await session.execute(include_query)
|
||||
includes = include_result.scalars().all()
|
||||
|
||||
for include in includes:
|
||||
included = await _resolve_profile_recursive(
|
||||
session, include.included_profile_id, new_visited, new_path
|
||||
)
|
||||
result.included_profiles.append({
|
||||
"id": str(included.profile_id),
|
||||
"name": included.profile_name,
|
||||
})
|
||||
|
||||
result.env_vars = _merge_env_vars(
|
||||
result.env_vars, included.env_vars, result.env_overrides, included.profile_name
|
||||
)
|
||||
result.runtime_hints = _merge_runtime_hints(
|
||||
result.runtime_hints,
|
||||
included.runtime_hints,
|
||||
result.hint_overrides,
|
||||
included.profile_name,
|
||||
)
|
||||
result.files = _merge_files(
|
||||
result.files, included.files, result.file_overrides, included.profile_name
|
||||
)
|
||||
result.mounts = _merge_mounts(
|
||||
result.mounts,
|
||||
[
|
||||
{"target": m.target, "mode": m.mode, "files": m.files}
|
||||
for m in included.mounts.values()
|
||||
],
|
||||
result.mount_overrides,
|
||||
included.profile_name,
|
||||
)
|
||||
|
||||
# Apply the profile's own settings (selected profile overrides includes)
|
||||
result.env_vars = _merge_env_vars(
|
||||
result.env_vars,
|
||||
profile.env_vars or {},
|
||||
result.env_overrides,
|
||||
profile.name,
|
||||
)
|
||||
result.runtime_hints = _merge_runtime_hints(
|
||||
result.runtime_hints,
|
||||
profile.runtime_hints or {},
|
||||
result.hint_overrides,
|
||||
profile.name,
|
||||
)
|
||||
result.files = _merge_files(
|
||||
result.files,
|
||||
profile.files or {},
|
||||
result.file_overrides,
|
||||
profile.name,
|
||||
)
|
||||
result.mounts = _merge_mounts(
|
||||
result.mounts,
|
||||
profile.mounts or [],
|
||||
result.mount_overrides,
|
||||
profile.name,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def resolve_profile(
|
||||
session: AsyncSession,
|
||||
profile_id: uuid.UUID,
|
||||
) -> ResolvedProfile:
|
||||
"""Resolve a config profile with all includes.
|
||||
|
||||
Args:
|
||||
session: Database session.
|
||||
profile_id: Profile ID to resolve.
|
||||
|
||||
Returns:
|
||||
ResolvedProfile with merged env vars, runtime hints, mounts, and files.
|
||||
|
||||
Raises:
|
||||
ConfigProfileCycleError: If a cycle is detected in includes.
|
||||
ConfigProfileNotFoundError: If the profile is not found.
|
||||
"""
|
||||
return await _resolve_profile_recursive(session, profile_id, set(), [])
|
||||
|
||||
|
||||
async def check_include_cycle(
|
||||
session: AsyncSession,
|
||||
profile_id: uuid.UUID,
|
||||
new_include_id: uuid.UUID | None = None,
|
||||
) -> list[uuid.UUID] | None:
|
||||
"""Check if adding an include would create a cycle.
|
||||
|
||||
Used at save time to validate include relationships before persisting.
|
||||
|
||||
Args:
|
||||
session: Database session.
|
||||
profile_id: The profile that would receive the new include.
|
||||
new_include_id: Optional new profile to include. If None, checks existing includes.
|
||||
|
||||
Returns:
|
||||
The cycle path as a list of UUIDs if a cycle exists, otherwise None.
|
||||
"""
|
||||
|
||||
async def _check_from(
|
||||
current_id: uuid.UUID,
|
||||
target_id: uuid.UUID,
|
||||
visited: set[uuid.UUID],
|
||||
path: list[uuid.UUID],
|
||||
) -> list[uuid.UUID] | None:
|
||||
if current_id in visited:
|
||||
if current_id == target_id:
|
||||
return path + [current_id]
|
||||
return None
|
||||
if current_id == target_id and path:
|
||||
return path + [current_id]
|
||||
|
||||
new_visited = visited | {current_id}
|
||||
new_path = path + [current_id]
|
||||
|
||||
include_query = (
|
||||
select(ConfigProfileInclude)
|
||||
.where(ConfigProfileInclude.profile_id == current_id)
|
||||
.order_by(ConfigProfileInclude.order_index)
|
||||
)
|
||||
include_result = await session.execute(include_query)
|
||||
includes = include_result.scalars().all()
|
||||
|
||||
for include in includes:
|
||||
cycle = await _check_from(
|
||||
include.included_profile_id, target_id, new_visited, new_path
|
||||
)
|
||||
if cycle is not None:
|
||||
return cycle
|
||||
return None
|
||||
|
||||
# Check if new_include_id can reach profile_id (would create cycle)
|
||||
if new_include_id is not None:
|
||||
cycle = await _check_from(new_include_id, profile_id, set(), [])
|
||||
if cycle is not None:
|
||||
return cycle
|
||||
|
||||
# Also check existing includes for cycles
|
||||
cycle = await _check_from(profile_id, profile_id, set(), [])
|
||||
if cycle is not None and len(cycle) > 1:
|
||||
return cycle
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def apply_resolved_profile(
|
||||
instance_dir: str,
|
||||
resolved: ResolvedProfile,
|
||||
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
|
||||
"""Apply a resolved profile to an instance directory.
|
||||
|
||||
Stages files, writes env vars, and prepares mount volumes.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to the instance directory.
|
||||
resolved: The resolved profile.
|
||||
|
||||
Returns:
|
||||
Tuple of (env_vars, files, volume_mounts, runtime_hints).
|
||||
env_vars: Merged environment variables.
|
||||
files: Relative file paths to content for the instance.
|
||||
volume_mounts: List of Docker volume mount dicts.
|
||||
runtime_hints: Extracted runtime hints.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
instance_path = Path(instance_dir)
|
||||
env_vars = dict(resolved.env_vars)
|
||||
files = dict(resolved.files)
|
||||
volume_mounts = []
|
||||
|
||||
# Write profile files to instance directory
|
||||
for file_path, content in files.items():
|
||||
full_path = instance_path / file_path
|
||||
try:
|
||||
full_path.resolve().relative_to(instance_path.resolve())
|
||||
except ValueError:
|
||||
logger.warning("Profile file path escapes instance directory: %s", file_path)
|
||||
continue
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
|
||||
# Stage mount files and prepare volume mounts
|
||||
for mount in resolved.mounts.values():
|
||||
mount_dir = instance_path / "mounts" / mount.target.lstrip("/").replace("/", "_")
|
||||
mount_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for file_path, content in mount.files.items():
|
||||
full_path = mount_dir / file_path
|
||||
try:
|
||||
full_path.resolve().relative_to(mount_dir.resolve())
|
||||
except ValueError:
|
||||
logger.warning("Mount file path escapes mount directory: %s", file_path)
|
||||
continue
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
|
||||
volume_mounts.append({
|
||||
"source": str(mount_dir),
|
||||
"target": mount.target,
|
||||
"type": "bind",
|
||||
})
|
||||
|
||||
return env_vars, files, volume_mounts, resolved.runtime_hints
|
||||
|
||||
|
||||
def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
|
||||
"""Convert a ResolvedProfile to a plain dict for serialization.
|
||||
|
||||
Args:
|
||||
resolved: The resolved profile.
|
||||
|
||||
Returns:
|
||||
Dict with env_vars, runtime_hints, mounts, files, and metadata.
|
||||
"""
|
||||
return {
|
||||
"profile_id": str(resolved.profile_id),
|
||||
"profile_name": resolved.profile_name,
|
||||
"env_vars": resolved.env_vars,
|
||||
"runtime_hints": resolved.runtime_hints,
|
||||
"mounts": [
|
||||
{
|
||||
"target": m.target,
|
||||
"mode": m.mode,
|
||||
"files": m.files,
|
||||
"overridden_files": m.overridden_files,
|
||||
}
|
||||
for m in resolved.mounts.values()
|
||||
],
|
||||
"files": resolved.files,
|
||||
"overrides": {
|
||||
"env_vars": resolved.env_overrides,
|
||||
"runtime_hints": resolved.hint_overrides,
|
||||
"files": resolved.file_overrides,
|
||||
"mounts": resolved.mount_overrides,
|
||||
},
|
||||
"included_profiles": resolved.included_profiles,
|
||||
}
|
||||
@@ -92,59 +92,6 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||
full_path.write_text(content)
|
||||
|
||||
|
||||
def write_config_folder_files(instance_dir: str, folders: list, project_id: str | None = None) -> list[dict]:
|
||||
"""Write config folder files to the instance directory and return volume mounts.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
folders: List of ConfigFolder objects
|
||||
project_id: Optional project ID for applying overrides
|
||||
|
||||
Returns:
|
||||
List of volume mount dicts [{"source": "...", "target": "...", "type": "..."}]
|
||||
"""
|
||||
instance_path = Path(instance_dir)
|
||||
volume_mounts = []
|
||||
|
||||
for folder in folders:
|
||||
# Determine mount path (with project override if applicable)
|
||||
mount_path = folder.mount_path
|
||||
files = folder.files.copy()
|
||||
|
||||
if project_id and folder.project_overrides:
|
||||
override = folder.project_overrides.get(str(project_id))
|
||||
if override:
|
||||
if override.get("mount_path"):
|
||||
mount_path = override["mount_path"]
|
||||
if override.get("files"):
|
||||
files.update(override["files"])
|
||||
|
||||
# Write files to instance directory
|
||||
folder_dir = instance_path / "volumes" / folder.name
|
||||
folder_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for file_path, content in files.items():
|
||||
# Security: ensure path doesn't escape folder_dir
|
||||
full_path = folder_dir / file_path
|
||||
try:
|
||||
full_path.resolve().relative_to(folder_dir.resolve())
|
||||
except ValueError:
|
||||
logger.warning("Config folder file path escapes directory: %s", file_path)
|
||||
continue
|
||||
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
|
||||
# Add volume mount
|
||||
volume_mounts.append({
|
||||
"source": str(folder_dir),
|
||||
"target": mount_path,
|
||||
"type": "bind",
|
||||
})
|
||||
|
||||
return volume_mounts
|
||||
|
||||
|
||||
def execute_compose_command(
|
||||
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||
) -> tuple[int, str, str]:
|
||||
|
||||
@@ -47,10 +47,8 @@ def test_client() -> Generator[TestClient, None, None]:
|
||||
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||
|
||||
# Patch startup events to prevent PostgreSQL connection attempts
|
||||
with patch("src.main.init_database") as mock_init, \
|
||||
patch("src.main.seed_builtin_tool_types") as mock_seed:
|
||||
with patch("src.main.init_database") as mock_init:
|
||||
mock_init.return_value = True
|
||||
mock_seed.return_value = None
|
||||
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
@@ -61,6 +59,31 @@ def test_client() -> Generator[TestClient, None, None]:
|
||||
asyncio.run(engine.dispose())
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(test_client) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Provide an async database session for unit tests."""
|
||||
# Get the override function from the test_client fixture
|
||||
override_fn = app.dependency_overrides.get(get_db_session)
|
||||
if override_fn:
|
||||
gen = override_fn()
|
||||
session = await gen.asend(None)
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await gen.aclose()
|
||||
else:
|
||||
# Fallback: create a new engine and session
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
||||
"""Provide an authenticated test client with a test user."""
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestConfigProfilesAPI:
|
||||
"""Integration tests for config profiles API."""
|
||||
|
||||
def test_list_config_profiles_requires_authentication(self, test_client: TestClient) -> None:
|
||||
"""Test that listing config profiles requires authentication."""
|
||||
response = test_client.get("/config-profiles")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_list_config_profiles_returns_user_profiles(self, authenticated_client: TestClient) -> None:
|
||||
"""Test that authenticated users can list their profiles."""
|
||||
response = authenticated_client.get("/config-profiles")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
def test_create_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||
"""Test creating a config profile."""
|
||||
response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "test-profile",
|
||||
"description": "Test profile",
|
||||
"env_vars": {"VAR": "value"},
|
||||
"runtime_hints": {"start_command": "npm start"},
|
||||
"mounts": [{"target": "/app", "mode": "rw", "files": {}}],
|
||||
"files": {"test.txt": "hello"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "test-profile"
|
||||
assert data["env_vars"] == {"VAR": "value"}
|
||||
assert data["files"] == {"test.txt": "hello"}
|
||||
assert data["mounts"][0]["target"] == "/app"
|
||||
|
||||
def test_create_config_profile_duplicate_name(self, authenticated_client: TestClient) -> None:
|
||||
"""Test that duplicate profile names are rejected."""
|
||||
# Create first profile
|
||||
response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "duplicate-profile",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
# Try to create second with same name
|
||||
response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "duplicate-profile",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
def test_create_config_profile_exceeds_size_limit(self, authenticated_client: TestClient) -> None:
|
||||
"""Test that profiles exceeding 10MB are rejected."""
|
||||
large_content = "x" * (11 * 1024 * 1024) # 11MB
|
||||
response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "large-profile",
|
||||
"env_vars": {},
|
||||
"files": {"large.txt": large_content},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 413
|
||||
|
||||
def test_create_config_profile_invalid_file_path(self, authenticated_client: TestClient) -> None:
|
||||
"""Test that invalid file paths are rejected."""
|
||||
response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "bad-profile",
|
||||
"env_vars": {},
|
||||
"files": {"../../../etc/passwd": "malicious"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_create_config_profile_invalid_mount_target(self, authenticated_client: TestClient) -> None:
|
||||
"""Test that invalid mount targets are rejected."""
|
||||
response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "bad-mount-profile",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
"mounts": [{"target": "relative/path", "mode": "rw", "files": {}}],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_get_config_profile_by_id(self, authenticated_client: TestClient) -> None:
|
||||
"""Test getting a config profile by ID."""
|
||||
# Create profile first
|
||||
create_response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "get-test",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
},
|
||||
)
|
||||
profile_id = create_response.json()["id"]
|
||||
|
||||
# Get it back
|
||||
response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "get-test"
|
||||
|
||||
def test_get_config_profile_not_found(self, authenticated_client: TestClient) -> None:
|
||||
"""Test getting a non-existent profile."""
|
||||
response = authenticated_client.get(f"/config-profiles/{uuid.uuid4()}")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_update_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||
"""Test updating a config profile."""
|
||||
# Create profile first
|
||||
create_response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "update-test",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
},
|
||||
)
|
||||
profile_id = create_response.json()["id"]
|
||||
|
||||
# Update it
|
||||
response = authenticated_client.put(
|
||||
f"/config-profiles/{profile_id}",
|
||||
json={
|
||||
"name": "updated-name",
|
||||
"env_vars": {"NEW_VAR": "new_value"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "updated-name"
|
||||
assert data["env_vars"] == {"NEW_VAR": "new_value"}
|
||||
|
||||
def test_delete_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||
"""Test deleting a config profile."""
|
||||
# Create profile first
|
||||
create_response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "delete-test",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
},
|
||||
)
|
||||
profile_id = create_response.json()["id"]
|
||||
|
||||
# Delete it
|
||||
response = authenticated_client.delete(f"/config-profiles/{profile_id}")
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify it's gone
|
||||
get_response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
||||
assert get_response.status_code == 404
|
||||
|
||||
def test_update_profile_includes_successfully(self, authenticated_client: TestClient) -> None:
|
||||
"""Test updating profile includes."""
|
||||
# Create base profile
|
||||
base_response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "base-profile",
|
||||
"env_vars": {"BASE_VAR": "base_value"},
|
||||
"files": {},
|
||||
},
|
||||
)
|
||||
base_id = base_response.json()["id"]
|
||||
|
||||
# Create child profile
|
||||
child_response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "child-profile",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
},
|
||||
)
|
||||
child_id = child_response.json()["id"]
|
||||
|
||||
# Update includes
|
||||
response = authenticated_client.put(
|
||||
f"/config-profiles/{child_id}/includes",
|
||||
json={"includes": [base_id]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
print(f"Response data: {data}")
|
||||
print(f"Includes: {data.get('includes', 'NO INCLUDES KEY')}")
|
||||
assert len(data["includes"]) == 1, f"Expected 1 include, got {len(data.get('includes', []))}: {data.get('includes', [])}"
|
||||
assert data["includes"][0]["included_profile_id"] == base_id
|
||||
|
||||
def test_update_profile_includes_cycle_detection(self, authenticated_client: TestClient) -> None:
|
||||
"""Test that include cycles are detected."""
|
||||
# Create profile A
|
||||
a_response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "profile-a",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
},
|
||||
)
|
||||
a_id = a_response.json()["id"]
|
||||
|
||||
# Create profile B
|
||||
b_response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "profile-b",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
},
|
||||
)
|
||||
b_id = b_response.json()["id"]
|
||||
|
||||
# Make B include A
|
||||
authenticated_client.put(
|
||||
f"/config-profiles/{b_id}/includes",
|
||||
json={"includes": [a_id]},
|
||||
)
|
||||
|
||||
# Try to make A include B (would create cycle)
|
||||
response = authenticated_client.put(
|
||||
f"/config-profiles/{a_id}/includes",
|
||||
json={"includes": [b_id]},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_preview_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||
"""Test previewing a resolved config profile."""
|
||||
# Create base profile
|
||||
base_response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "preview-base",
|
||||
"env_vars": {"BASE_VAR": "base"},
|
||||
"files": {},
|
||||
},
|
||||
)
|
||||
base_id = base_response.json()["id"]
|
||||
|
||||
# Create child profile
|
||||
child_response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "preview-child",
|
||||
"env_vars": {"CHILD_VAR": "child"},
|
||||
"files": {},
|
||||
},
|
||||
)
|
||||
child_id = child_response.json()["id"]
|
||||
|
||||
# Make child include base
|
||||
authenticated_client.put(
|
||||
f"/config-profiles/{child_id}/includes",
|
||||
json={"includes": [base_id]},
|
||||
)
|
||||
|
||||
# Preview child
|
||||
response = authenticated_client.get(f"/config-profiles/{child_id}/preview")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["profile_name"] == "preview-child"
|
||||
assert data["env_vars"]["BASE_VAR"] == "base"
|
||||
assert data["env_vars"]["CHILD_VAR"] == "child"
|
||||
assert len(data["included_profiles"]) == 1
|
||||
|
||||
def test_resolve_default_profile(self, authenticated_client: TestClient) -> None:
|
||||
"""Test resolving default profile for project/tool."""
|
||||
# Create a global default profile (no project/tool scoping)
|
||||
authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "default-profile",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
"is_default": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Resolve default with random project/tool (should fall back to global)
|
||||
project_id = str(uuid.uuid4())
|
||||
tool_type_id = str(uuid.uuid4())
|
||||
response = authenticated_client.get(
|
||||
"/config-profiles/defaults/resolve",
|
||||
params={"project_id": project_id, "tool_type_id": tool_type_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["profile_name"] == "default-profile"
|
||||
|
||||
def test_resolve_default_profile_no_match(self, authenticated_client: TestClient) -> None:
|
||||
"""Test resolving default profile when no profiles exist."""
|
||||
project_id = str(uuid.uuid4())
|
||||
tool_type_id = str(uuid.uuid4())
|
||||
|
||||
response = authenticated_client.get(
|
||||
"/config-profiles/defaults/resolve",
|
||||
params={"project_id": project_id, "tool_type_id": tool_type_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["profile_id"] is None
|
||||
@@ -0,0 +1,383 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
from src.services.config_profile_resolver import (
|
||||
ConfigProfileCycleError,
|
||||
ConfigProfileNotFoundError,
|
||||
ResolvedProfile,
|
||||
check_include_cycle,
|
||||
resolve_profile,
|
||||
_merge_env_vars,
|
||||
_merge_files,
|
||||
_merge_mounts,
|
||||
_merge_runtime_hints,
|
||||
)
|
||||
|
||||
|
||||
class TestMergeFunctions:
|
||||
"""Unit tests for merge helper functions."""
|
||||
|
||||
def test_merge_env_vars_basic(self) -> None:
|
||||
"""Test basic env var merging."""
|
||||
result = _merge_env_vars(
|
||||
{"A": "1", "B": "2"},
|
||||
{"B": "3", "C": "4"},
|
||||
{},
|
||||
"source",
|
||||
)
|
||||
assert result == {"A": "1", "B": "3", "C": "4"}
|
||||
|
||||
def test_merge_env_vars_tracks_overrides(self) -> None:
|
||||
"""Test that env var overrides are tracked."""
|
||||
overrides = {}
|
||||
_merge_env_vars(
|
||||
{"A": "1"},
|
||||
{"A": "2"},
|
||||
overrides,
|
||||
"source",
|
||||
)
|
||||
assert overrides == {"A": "source"}
|
||||
|
||||
def test_merge_runtime_hints_basic(self) -> None:
|
||||
"""Test basic runtime hint merging."""
|
||||
result = _merge_runtime_hints(
|
||||
{"command": "old"},
|
||||
{"command": "new", "port": 8080},
|
||||
{},
|
||||
"source",
|
||||
)
|
||||
assert result == {"command": "new", "port": 8080}
|
||||
|
||||
def test_merge_files_basic(self) -> None:
|
||||
"""Test basic file merging."""
|
||||
result = _merge_files(
|
||||
{"a.txt": "old"},
|
||||
{"a.txt": "new", "b.txt": "content"},
|
||||
{},
|
||||
"source",
|
||||
)
|
||||
assert result == {"a.txt": "new", "b.txt": "content"}
|
||||
|
||||
def test_merge_mounts_basic(self) -> None:
|
||||
"""Test basic mount merging."""
|
||||
from src.services.config_profile_resolver import ResolvedMount
|
||||
result = _merge_mounts(
|
||||
{},
|
||||
[{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}],
|
||||
{},
|
||||
"source",
|
||||
)
|
||||
assert "/app" in result
|
||||
assert result["/app"].mode == "rw"
|
||||
assert result["/app"].files == {"a.txt": "content"}
|
||||
|
||||
def test_merge_mounts_file_override(self) -> None:
|
||||
"""Test mount file map merging with overrides."""
|
||||
from src.services.config_profile_resolver import ResolvedMount
|
||||
result = _merge_mounts(
|
||||
{"/app": ResolvedMount(target="/app", mode="rw", files={"a.txt": "old"})},
|
||||
[{"target": "/app", "mode": "rw", "files": {"a.txt": "new"}}],
|
||||
{},
|
||||
"source",
|
||||
)
|
||||
assert result["/app"].files == {"a.txt": "new"}
|
||||
|
||||
def test_merge_mounts_mode_conflict(self) -> None:
|
||||
"""Test that mount mode conflicts are resolved (later wins)."""
|
||||
from src.services.config_profile_resolver import ResolvedMount
|
||||
overrides = {}
|
||||
result = _merge_mounts(
|
||||
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
|
||||
[{"target": "/app", "mode": "ro", "files": {}}],
|
||||
overrides,
|
||||
"source",
|
||||
)
|
||||
assert result["/app"].mode == "ro"
|
||||
assert overrides == {"/app": "source"}
|
||||
|
||||
|
||||
class TestResolveProfile:
|
||||
"""Unit tests for profile resolution."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_simple_profile(self, db_session: AsyncSession) -> None:
|
||||
"""Test resolving a profile with no includes."""
|
||||
user_id = uuid.uuid4()
|
||||
profile = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
name="simple",
|
||||
env_vars={"VAR": "value"},
|
||||
runtime_hints={"command": "run"},
|
||||
files={"test.txt": "content"},
|
||||
mounts=[{"target": "/app", "mode": "rw", "files": {}}],
|
||||
)
|
||||
db_session.add(profile)
|
||||
await db_session.commit()
|
||||
|
||||
result = await resolve_profile(db_session, profile.id)
|
||||
assert result.profile_name == "simple"
|
||||
assert result.env_vars == {"VAR": "value"}
|
||||
assert result.runtime_hints == {"command": "run"}
|
||||
assert result.files == {"test.txt": "content"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_profile_with_includes(self, db_session: AsyncSession) -> None:
|
||||
"""Test resolving a profile that includes another."""
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
# Create base profile
|
||||
base = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
name="base",
|
||||
env_vars={"BASE_VAR": "base_value"},
|
||||
files={},
|
||||
)
|
||||
db_session.add(base)
|
||||
|
||||
# Create child profile
|
||||
child = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
name="child",
|
||||
env_vars={"CHILD_VAR": "child_value"},
|
||||
files={},
|
||||
)
|
||||
db_session.add(child)
|
||||
await db_session.commit()
|
||||
|
||||
# Create include relationship
|
||||
include = ConfigProfileInclude(
|
||||
id=uuid.uuid4(),
|
||||
profile_id=child.id,
|
||||
included_profile_id=base.id,
|
||||
order_index=0,
|
||||
)
|
||||
db_session.add(include)
|
||||
await db_session.commit()
|
||||
|
||||
result = await resolve_profile(db_session, child.id)
|
||||
assert result.env_vars == {
|
||||
"BASE_VAR": "base_value",
|
||||
"CHILD_VAR": "child_value",
|
||||
}
|
||||
assert len(result.included_profiles) == 1
|
||||
assert result.included_profiles[0]["name"] == "base"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_profile_child_overrides_parent(self, db_session: AsyncSession) -> None:
|
||||
"""Test that child profile values override parent values."""
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
base = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
name="base",
|
||||
env_vars={"VAR": "base"},
|
||||
files={},
|
||||
)
|
||||
db_session.add(base)
|
||||
|
||||
child = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
name="child",
|
||||
env_vars={"VAR": "child"},
|
||||
files={},
|
||||
)
|
||||
db_session.add(child)
|
||||
await db_session.commit()
|
||||
|
||||
include = ConfigProfileInclude(
|
||||
id=uuid.uuid4(),
|
||||
profile_id=child.id,
|
||||
included_profile_id=base.id,
|
||||
order_index=0,
|
||||
)
|
||||
db_session.add(include)
|
||||
await db_session.commit()
|
||||
|
||||
result = await resolve_profile(db_session, child.id)
|
||||
assert result.env_vars == {"VAR": "child"}
|
||||
assert result.env_overrides == {"VAR": "child"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_profile_cycle_detection(self, db_session: AsyncSession) -> None:
|
||||
"""Test that cycles are detected during resolution."""
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
profile_a = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
name="a",
|
||||
env_vars={},
|
||||
files={},
|
||||
)
|
||||
db_session.add(profile_a)
|
||||
|
||||
profile_b = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
name="b",
|
||||
env_vars={},
|
||||
files={},
|
||||
)
|
||||
db_session.add(profile_b)
|
||||
await db_session.commit()
|
||||
|
||||
# A includes B
|
||||
include_ab = ConfigProfileInclude(
|
||||
id=uuid.uuid4(),
|
||||
profile_id=profile_a.id,
|
||||
included_profile_id=profile_b.id,
|
||||
order_index=0,
|
||||
)
|
||||
db_session.add(include_ab)
|
||||
|
||||
# B includes A (creates cycle)
|
||||
include_ba = ConfigProfileInclude(
|
||||
id=uuid.uuid4(),
|
||||
profile_id=profile_b.id,
|
||||
included_profile_id=profile_a.id,
|
||||
order_index=0,
|
||||
)
|
||||
db_session.add(include_ba)
|
||||
await db_session.commit()
|
||||
|
||||
with pytest.raises(ConfigProfileCycleError):
|
||||
await resolve_profile(db_session, profile_a.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_profile_not_found(self, db_session: AsyncSession) -> None:
|
||||
"""Test resolving a non-existent profile."""
|
||||
with pytest.raises(ConfigProfileNotFoundError):
|
||||
await resolve_profile(db_session, uuid.uuid4())
|
||||
|
||||
|
||||
class TestCheckIncludeCycle:
|
||||
"""Unit tests for include cycle checking."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_no_cycle(self, db_session: AsyncSession) -> None:
|
||||
"""Test checking when no cycle exists."""
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
profile_a = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
name="a",
|
||||
env_vars={},
|
||||
files={},
|
||||
)
|
||||
db_session.add(profile_a)
|
||||
|
||||
profile_b = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
name="b",
|
||||
env_vars={},
|
||||
files={},
|
||||
)
|
||||
db_session.add(profile_b)
|
||||
await db_session.commit()
|
||||
|
||||
# A includes B
|
||||
include = ConfigProfileInclude(
|
||||
id=uuid.uuid4(),
|
||||
profile_id=profile_a.id,
|
||||
included_profile_id=profile_b.id,
|
||||
order_index=0,
|
||||
)
|
||||
db_session.add(include)
|
||||
await db_session.commit()
|
||||
|
||||
result = await check_include_cycle(db_session, profile_a.id)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_detects_cycle(self, db_session: AsyncSession) -> None:
|
||||
"""Test detecting an existing cycle."""
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
profile_a = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
name="a",
|
||||
env_vars={},
|
||||
files={},
|
||||
)
|
||||
db_session.add(profile_a)
|
||||
|
||||
profile_b = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
name="b",
|
||||
env_vars={},
|
||||
files={},
|
||||
)
|
||||
db_session.add(profile_b)
|
||||
await db_session.commit()
|
||||
|
||||
# A includes B
|
||||
include_ab = ConfigProfileInclude(
|
||||
id=uuid.uuid4(),
|
||||
profile_id=profile_a.id,
|
||||
included_profile_id=profile_b.id,
|
||||
order_index=0,
|
||||
)
|
||||
db_session.add(include_ab)
|
||||
|
||||
# B includes A
|
||||
include_ba = ConfigProfileInclude(
|
||||
id=uuid.uuid4(),
|
||||
profile_id=profile_b.id,
|
||||
included_profile_id=profile_a.id,
|
||||
order_index=0,
|
||||
)
|
||||
db_session.add(include_ba)
|
||||
await db_session.commit()
|
||||
|
||||
result = await check_include_cycle(db_session, profile_a.id)
|
||||
assert result is not None
|
||||
assert len(result) > 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_would_create_cycle(self, db_session: AsyncSession) -> None:
|
||||
"""Test detecting a cycle that would be created."""
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
profile_a = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
name="a",
|
||||
env_vars={},
|
||||
files={},
|
||||
)
|
||||
db_session.add(profile_a)
|
||||
|
||||
profile_b = ConfigProfile(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
name="b",
|
||||
env_vars={},
|
||||
files={},
|
||||
)
|
||||
db_session.add(profile_b)
|
||||
await db_session.commit()
|
||||
|
||||
# A includes B
|
||||
include = ConfigProfileInclude(
|
||||
id=uuid.uuid4(),
|
||||
profile_id=profile_a.id,
|
||||
included_profile_id=profile_b.id,
|
||||
order_index=0,
|
||||
)
|
||||
db_session.add(include)
|
||||
await db_session.commit()
|
||||
|
||||
# Check if adding B includes A would create cycle
|
||||
result = await check_include_cycle(db_session, profile_b.id, profile_a.id)
|
||||
assert result is not None
|
||||
@@ -0,0 +1,145 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface ConfigProfile {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
project_id: string | null;
|
||||
tool_type_id: string | null;
|
||||
env_vars: Record<string, string>;
|
||||
runtime_hints: Record<string, unknown>;
|
||||
mounts: ConfigProfileMount[];
|
||||
files: Record<string, string>;
|
||||
is_default: boolean;
|
||||
includes: ConfigProfileInclude[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ConfigProfileMount {
|
||||
target: string;
|
||||
mode: "ro" | "rw";
|
||||
files: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ConfigProfileInclude {
|
||||
id: string;
|
||||
included_profile_id: string;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
export interface ResolvedProfile {
|
||||
profile_id: string;
|
||||
profile_name: string;
|
||||
env_vars: Record<string, string>;
|
||||
runtime_hints: Record<string, unknown>;
|
||||
mounts: ResolvedMount[];
|
||||
files: Record<string, string>;
|
||||
overrides: {
|
||||
env_vars: Record<string, string>;
|
||||
runtime_hints: Record<string, string>;
|
||||
files: Record<string, string>;
|
||||
mounts: Record<string, string>;
|
||||
};
|
||||
included_profiles: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
export interface ResolvedMount {
|
||||
target: string;
|
||||
mode: "ro" | "rw";
|
||||
files: Record<string, string>;
|
||||
overridden_files: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CreateConfigProfileRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
project_id?: string;
|
||||
tool_type_id?: string;
|
||||
env_vars?: Record<string, string>;
|
||||
runtime_hints?: Record<string, unknown>;
|
||||
mounts?: ConfigProfileMount[];
|
||||
files?: Record<string, string>;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateConfigProfileRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
project_id?: string;
|
||||
tool_type_id?: string;
|
||||
env_vars?: Record<string, string>;
|
||||
runtime_hints?: Record<string, unknown>;
|
||||
mounts?: ConfigProfileMount[];
|
||||
files?: Record<string, string>;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateIncludesRequest {
|
||||
includes: string[];
|
||||
}
|
||||
|
||||
export const listConfigProfiles = async (
|
||||
projectId?: string,
|
||||
toolTypeId?: string
|
||||
): Promise<ConfigProfile[]> => {
|
||||
const response = await apiClient.get<ConfigProfile[]>("/config-profiles", {
|
||||
params: { project_id: projectId, tool_type_id: toolTypeId },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getConfigProfile = async (id: string): Promise<ConfigProfile> => {
|
||||
const response = await apiClient.get<ConfigProfile>(`/config-profiles/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createConfigProfile = async (
|
||||
data: CreateConfigProfileRequest
|
||||
): Promise<ConfigProfile> => {
|
||||
const response = await apiClient.post<ConfigProfile>("/config-profiles", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateConfigProfile = async (
|
||||
id: string,
|
||||
data: UpdateConfigProfileRequest
|
||||
): Promise<ConfigProfile> => {
|
||||
const response = await apiClient.put<ConfigProfile>(`/config-profiles/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteConfigProfile = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/config-profiles/${id}`);
|
||||
};
|
||||
|
||||
export const updateProfileIncludes = async (
|
||||
id: string,
|
||||
data: UpdateIncludesRequest
|
||||
): Promise<ConfigProfile> => {
|
||||
const response = await apiClient.put<ConfigProfile>(
|
||||
`/config-profiles/${id}/includes`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const previewConfigProfile = async (
|
||||
id: string
|
||||
): Promise<ResolvedProfile> => {
|
||||
const response = await apiClient.get<ResolvedProfile>(
|
||||
`/config-profiles/${id}/preview`
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const resolveDefaultProfile = async (
|
||||
projectId: string,
|
||||
toolTypeId: string
|
||||
): Promise<{ profile_id: string | null; profile_name: string | null }> => {
|
||||
const response = await apiClient.get("/config-profiles/defaults/resolve", {
|
||||
params: { project_id: projectId, tool_type_id: toolTypeId },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
@@ -10,6 +10,7 @@ export interface ToolInstance {
|
||||
status: string;
|
||||
url: string | null;
|
||||
port: number | null;
|
||||
selected_config_profile_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -49,7 +50,8 @@ export async function createInstance(
|
||||
displayName?: string,
|
||||
cloneMode?: string,
|
||||
branch?: string,
|
||||
newBranch?: string
|
||||
newBranch?: string,
|
||||
configProfileId?: string
|
||||
): Promise<ToolInstance> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||
@@ -59,6 +61,7 @@ export async function createInstance(
|
||||
clone_mode: cloneMode || "mount",
|
||||
branch: branch || undefined,
|
||||
new_branch: newBranch || undefined,
|
||||
config_profile_id: configProfileId,
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
@@ -67,10 +70,12 @@ export async function createInstance(
|
||||
export async function startInstance(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string
|
||||
instanceId: string,
|
||||
configProfileId?: string
|
||||
): Promise<{ status: string; url?: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
||||
{ config_profile_id: configProfileId }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -89,10 +94,12 @@ export async function stopInstance(
|
||||
export async function restartInstance(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string
|
||||
instanceId: string,
|
||||
configProfileId?: string
|
||||
): Promise<{ status: string; url?: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
||||
{ config_profile_id: configProfileId }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Project } from "../types";
|
||||
import { listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories";
|
||||
import type { ToolType } from "../api/tool_types";
|
||||
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
||||
|
||||
interface CreateSessionFormProps {
|
||||
projects: Project[];
|
||||
@@ -46,6 +47,8 @@ export const CreateSessionForm = ({
|
||||
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
|
||||
const [branch, setBranch] = useState("main");
|
||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
||||
const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
|
||||
|
||||
const [branches, setBranches] = useState<Branch[]>([]);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
@@ -71,6 +74,30 @@ export const CreateSessionForm = ({
|
||||
void loadKeys();
|
||||
}, [showCloneMode]);
|
||||
|
||||
// Load config profiles when tool type is selected
|
||||
useEffect(() => {
|
||||
const projectId = fixedProjectId || selectedProject;
|
||||
if (!selectedToolType || !projectId) {
|
||||
setConfigProfiles([]);
|
||||
setSelectedConfigProfile("");
|
||||
return;
|
||||
}
|
||||
const loadProfiles = async () => {
|
||||
try {
|
||||
const profiles = await listConfigProfiles(projectId, selectedToolType);
|
||||
setConfigProfiles(profiles);
|
||||
// Auto-select default if available
|
||||
const defaultProfile = profiles.find((p) => p.is_default);
|
||||
if (defaultProfile) {
|
||||
setSelectedConfigProfile(defaultProfile.id);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadProfiles();
|
||||
}, [selectedToolType, selectedProject, fixedProjectId]);
|
||||
|
||||
// Load branches when selected repo changes
|
||||
useEffect(() => {
|
||||
const projectId = fixedProjectId || selectedProject;
|
||||
@@ -138,7 +165,8 @@ export const CreateSessionForm = ({
|
||||
: undefined,
|
||||
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
|
||||
? newBranchName
|
||||
: undefined
|
||||
: undefined,
|
||||
selectedConfigProfile || undefined
|
||||
);
|
||||
|
||||
setProgress("Starting container...");
|
||||
@@ -298,8 +326,26 @@ export const CreateSessionForm = ({
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Step 4: Clone Mode & Branch */}
|
||||
{showCloneMode && hasToolType && renderStep("Repository Access", 4, true, false,
|
||||
{/* Step 4: Config Profile */}
|
||||
{hasToolType && renderStep("Config Profile (optional)", 4, true, false,
|
||||
<label className="form-field">
|
||||
<select
|
||||
value={selectedConfigProfile}
|
||||
onChange={(e) => setSelectedConfigProfile(e.target.value)}
|
||||
disabled={!hasToolType || isSubmitting}
|
||||
>
|
||||
<option value="">No profile (use tool defaults)</option>
|
||||
{configProfiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} {p.is_default ? "(default)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Step 5: Clone Mode & Branch */}
|
||||
{showCloneMode && hasToolType && renderStep("Repository Access", 5, true, false,
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
<div className="radio-group">
|
||||
@@ -422,8 +468,8 @@ export const CreateSessionForm = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 5: Display Name */}
|
||||
{hasToolType && renderStep("Display Name (optional)", 5, true, !!displayName,
|
||||
{/* Step 6: Display Name */}
|
||||
{hasToolType && renderStep("Display Name (optional)", 6, true, !!displayName,
|
||||
<label className="form-field">
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../api/sessions";
|
||||
import type { ToolType } from "../api/tool_types";
|
||||
import { CreateSessionForm } from "./create-session-form";
|
||||
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||
|
||||
@@ -30,13 +31,18 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
||||
// Stop confirmation
|
||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||
|
||||
|
||||
// Health check state
|
||||
const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; lastCheck: number }>>({});
|
||||
|
||||
// Config profile selection for start/restart
|
||||
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
||||
const [profileSelectInstanceId, setProfileSelectInstanceId] = useState<string | null>(null);
|
||||
const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
|
||||
|
||||
const loadInstances = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -88,9 +94,20 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
||||
await loadInstances();
|
||||
};
|
||||
|
||||
const handleStart = async (instanceId: string) => {
|
||||
const loadConfigProfiles = useCallback(async (toolTypeId: string) => {
|
||||
try {
|
||||
await startInstance(projectId, repoId, instanceId);
|
||||
const profiles = await listConfigProfiles(projectId, toolTypeId);
|
||||
setConfigProfiles(profiles);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const handleStart = async (instanceId: string, configProfileId?: string) => {
|
||||
try {
|
||||
await startInstance(projectId, repoId, instanceId, configProfileId);
|
||||
setProfileSelectInstanceId(null);
|
||||
setSelectedProfileForAction("");
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to start instance");
|
||||
@@ -107,9 +124,11 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async (instanceId: string) => {
|
||||
const handleRestart = async (instanceId: string, configProfileId?: string) => {
|
||||
try {
|
||||
await restartInstance(projectId, repoId, instanceId);
|
||||
await restartInstance(projectId, repoId, instanceId, configProfileId);
|
||||
setProfileSelectInstanceId(null);
|
||||
setSelectedProfileForAction("");
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to restart instance");
|
||||
@@ -199,6 +218,13 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{instance.selected_config_profile_id && (
|
||||
<div className="instance-profile">
|
||||
<span className="badge">
|
||||
Profile: {configProfiles.find((p) => p.id === instance.selected_config_profile_id)?.name || instance.selected_config_profile_id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="instance-actions">
|
||||
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
|
||||
@@ -236,14 +262,57 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
||||
</button>
|
||||
)}
|
||||
{instance.status !== "running" && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => void handleStart(instance.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="play" size="sm" />
|
||||
Start
|
||||
</button>
|
||||
<>
|
||||
{profileSelectInstanceId === instance.id ? (
|
||||
<div className="inline-profile-select">
|
||||
<select
|
||||
value={selectedProfileForAction}
|
||||
onChange={(e) => setSelectedProfileForAction(e.target.value)}
|
||||
>
|
||||
<option value="">Default (none)</option>
|
||||
{configProfiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="primary-button small"
|
||||
onClick={() => void handleStart(instance.id, selectedProfileForAction || undefined)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="play" size="sm" />
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => {
|
||||
setProfileSelectInstanceId(null);
|
||||
setSelectedProfileForAction("");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => {
|
||||
const toolType = toolTypes.find((t) => t.id === instance.tool_type_id);
|
||||
if (toolType) {
|
||||
void loadConfigProfiles(toolType.id);
|
||||
}
|
||||
setProfileSelectInstanceId(instance.id);
|
||||
setSelectedProfileForAction(instance.selected_config_profile_id || "");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="play" size="sm" />
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{instance.status === "running" && (
|
||||
<>
|
||||
@@ -274,13 +343,54 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
||||
<Icon name="stop" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => void handleRestart(instance.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
</button>
|
||||
{profileSelectInstanceId === instance.id ? (
|
||||
<div className="inline-profile-select">
|
||||
<select
|
||||
value={selectedProfileForAction}
|
||||
onChange={(e) => setSelectedProfileForAction(e.target.value)}
|
||||
>
|
||||
<option value="">Default (none)</option>
|
||||
{configProfiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="primary-button small"
|
||||
onClick={() => void handleRestart(instance.id, selectedProfileForAction || undefined)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => {
|
||||
setProfileSelectInstanceId(null);
|
||||
setSelectedProfileForAction("");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => {
|
||||
const toolType = toolTypes.find((t) => t.id === instance.tool_type_id);
|
||||
if (toolType) {
|
||||
void loadConfigProfiles(toolType.id);
|
||||
}
|
||||
setProfileSelectInstanceId(instance.id);
|
||||
setSelectedProfileForAction(instance.selected_config_profile_id || "");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
createConfigProfile,
|
||||
deleteConfigProfile,
|
||||
listConfigProfiles,
|
||||
previewConfigProfile,
|
||||
updateConfigProfile,
|
||||
type ConfigProfile,
|
||||
type CreateConfigProfileRequest,
|
||||
type ResolvedProfile,
|
||||
} from "../api/config_profiles";
|
||||
import { Icon } from "../components/icon";
|
||||
|
||||
export const ConfigProfilesPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingProfile, setEditingProfile] = useState<ConfigProfile | null>(null);
|
||||
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<CreateConfigProfileRequest>({
|
||||
name: "",
|
||||
description: "",
|
||||
env_vars: {},
|
||||
runtime_hints: {},
|
||||
mounts: [],
|
||||
files: {},
|
||||
is_default: false,
|
||||
});
|
||||
|
||||
const loadProfiles = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await listConfigProfiles();
|
||||
setProfiles(data);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError("Failed to load config profiles");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfiles();
|
||||
}, [loadProfiles]);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: "",
|
||||
description: "",
|
||||
env_vars: {},
|
||||
runtime_hints: {},
|
||||
mounts: [],
|
||||
files: {},
|
||||
is_default: false,
|
||||
});
|
||||
setEditingProfile(null);
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name?.trim()) return;
|
||||
|
||||
try {
|
||||
await createConfigProfile(formData);
|
||||
resetForm();
|
||||
await loadProfiles();
|
||||
} catch {
|
||||
setError("Failed to create config profile");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editingProfile || !formData.name?.trim()) return;
|
||||
|
||||
try {
|
||||
await updateConfigProfile(editingProfile.id, formData);
|
||||
resetForm();
|
||||
await loadProfiles();
|
||||
} catch {
|
||||
setError("Failed to update config profile");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Are you sure you want to delete this config profile?")) return;
|
||||
|
||||
try {
|
||||
await deleteConfigProfile(id);
|
||||
await loadProfiles();
|
||||
} catch {
|
||||
setError("Failed to delete config profile");
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (profile: ConfigProfile) => {
|
||||
setEditingProfile(profile);
|
||||
setFormData({
|
||||
name: profile.name,
|
||||
description: profile.description || undefined,
|
||||
project_id: profile.project_id || undefined,
|
||||
tool_type_id: profile.tool_type_id || undefined,
|
||||
env_vars: profile.env_vars,
|
||||
runtime_hints: profile.runtime_hints,
|
||||
mounts: profile.mounts,
|
||||
files: profile.files,
|
||||
is_default: profile.is_default,
|
||||
});
|
||||
setShowForm(true);
|
||||
setPreviewData(null);
|
||||
setPreviewingId(null);
|
||||
};
|
||||
|
||||
const handlePreview = async (id: string) => {
|
||||
try {
|
||||
setPreviewingId(id);
|
||||
const data = await previewConfigProfile(id);
|
||||
setPreviewData(data);
|
||||
} catch {
|
||||
setError("Failed to preview config profile");
|
||||
} finally {
|
||||
setPreviewingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateFormField = <K extends keyof CreateConfigProfileRequest>(
|
||||
key: K,
|
||||
value: CreateConfigProfileRequest[K]
|
||||
) => {
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const addEnvVar = () => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
env_vars: { ...prev.env_vars, "": "" },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateEnvVar = (oldKey: string, newKey: string, value: string) => {
|
||||
setFormData((prev) => {
|
||||
const envVars = { ...prev.env_vars };
|
||||
if (oldKey !== newKey) {
|
||||
delete envVars[oldKey];
|
||||
}
|
||||
envVars[newKey] = value;
|
||||
return { ...prev, env_vars: envVars };
|
||||
});
|
||||
};
|
||||
|
||||
const removeEnvVar = (key: string) => {
|
||||
setFormData((prev) => {
|
||||
const envVars = { ...prev.env_vars };
|
||||
delete envVars[key];
|
||||
return { ...prev, env_vars: envVars };
|
||||
});
|
||||
};
|
||||
|
||||
const addFile = () => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
files: { ...prev.files, "": "" },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateFile = (oldPath: string, newPath: string, content: string) => {
|
||||
setFormData((prev) => {
|
||||
const files = { ...prev.files };
|
||||
if (oldPath !== newPath) {
|
||||
delete files[oldPath];
|
||||
}
|
||||
files[newPath] = content;
|
||||
return { ...prev, files };
|
||||
});
|
||||
};
|
||||
|
||||
const removeFile = (path: string) => {
|
||||
setFormData((prev) => {
|
||||
const files = { ...prev.files };
|
||||
delete files[path];
|
||||
return { ...prev, files };
|
||||
});
|
||||
};
|
||||
|
||||
const addMount = () => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
mounts: [...(prev.mounts || []), { target: "/", mode: "rw", files: {} }],
|
||||
}));
|
||||
};
|
||||
|
||||
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
|
||||
setFormData((prev) => {
|
||||
const mounts = [...(prev.mounts || [])];
|
||||
mounts[index] = { ...mounts[index], ...updates };
|
||||
return { ...prev, mounts };
|
||||
});
|
||||
};
|
||||
|
||||
const removeMount = (index: number) => {
|
||||
setFormData((prev) => {
|
||||
const mounts = [...(prev.mounts || [])];
|
||||
mounts.splice(index, 1);
|
||||
return { ...prev, mounts };
|
||||
});
|
||||
};
|
||||
|
||||
const addMountFile = (mountIndex: number) => {
|
||||
setFormData((prev) => {
|
||||
const mounts = [...(prev.mounts || [])];
|
||||
mounts[mountIndex] = {
|
||||
...mounts[mountIndex],
|
||||
files: { ...mounts[mountIndex].files, "": "" },
|
||||
};
|
||||
return { ...prev, mounts };
|
||||
});
|
||||
};
|
||||
|
||||
const updateMountFile = (
|
||||
mountIndex: number,
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
content: string
|
||||
) => {
|
||||
setFormData((prev) => {
|
||||
const mounts = [...(prev.mounts || [])];
|
||||
const files = { ...mounts[mountIndex].files };
|
||||
if (oldPath !== newPath) {
|
||||
delete files[oldPath];
|
||||
}
|
||||
files[newPath] = content;
|
||||
mounts[mountIndex] = { ...mounts[mountIndex], files };
|
||||
return { ...prev, mounts };
|
||||
});
|
||||
};
|
||||
|
||||
const removeMountFile = (mountIndex: number, path: string) => {
|
||||
setFormData((prev) => {
|
||||
const mounts = [...(prev.mounts || [])];
|
||||
const files = { ...mounts[mountIndex].files };
|
||||
delete files[path];
|
||||
mounts[mountIndex] = { ...mounts[mountIndex], files };
|
||||
return { ...prev, mounts };
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) return <div>Loading config profiles...</div>;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h1>Config Profiles</h1>
|
||||
</div>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
|
||||
Back to settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{!showForm && (
|
||||
<button
|
||||
className="primary-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setShowForm(true);
|
||||
}}
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
Create Profile
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={editingProfile ? handleUpdate : handleCreate} className="card stack">
|
||||
<h3>{editingProfile ? "Edit Profile" : "Create Profile"}</h3>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="profile-name">Name *</label>
|
||||
<input
|
||||
id="profile-name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => updateFormField("name", e.target.value)}
|
||||
placeholder="e.g., Development Environment"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="profile-description">Description</label>
|
||||
<input
|
||||
id="profile-description"
|
||||
type="text"
|
||||
value={formData.description || ""}
|
||||
onChange={(e) => updateFormField("description", e.target.value || undefined)}
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="profile-project">Project ID</label>
|
||||
<input
|
||||
id="profile-project"
|
||||
type="text"
|
||||
value={formData.project_id || ""}
|
||||
onChange={(e) => updateFormField("project_id", e.target.value || undefined)}
|
||||
placeholder="Optional project UUID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="profile-tool">Tool Type ID</label>
|
||||
<input
|
||||
id="profile-tool"
|
||||
type="text"
|
||||
value={formData.tool_type_id || ""}
|
||||
onChange={(e) => updateFormField("tool_type_id", e.target.value || undefined)}
|
||||
placeholder="Optional tool type UUID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_default || false}
|
||||
onChange={(e) => updateFormField("is_default", e.target.checked)}
|
||||
/>
|
||||
Set as default for this scope
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h4>Environment Variables</h4>
|
||||
{Object.entries(formData.env_vars || {}).map(([key, value]) => (
|
||||
<div key={key} className="form-row">
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
onChange={(e) => updateEnvVar(key, e.target.value, value)}
|
||||
placeholder="VAR_NAME"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => updateEnvVar(key, key, e.target.value)}
|
||||
placeholder="value"
|
||||
/>
|
||||
<button type="button" className="danger-button" onClick={() => removeEnvVar(key)}>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="secondary-button" onClick={addEnvVar}>
|
||||
<Icon name="add" size="sm" />
|
||||
Add Variable
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h4>Runtime Hints</h4>
|
||||
<textarea
|
||||
value={JSON.stringify(formData.runtime_hints || {}, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
updateFormField("runtime_hints", parsed);
|
||||
} catch {
|
||||
// Invalid JSON, ignore
|
||||
}
|
||||
}}
|
||||
placeholder='{"start_command": "npm start"}'
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h4>Files</h4>
|
||||
{Object.entries(formData.files || {}).map(([path, content]) => (
|
||||
<div key={path} className="file-entry">
|
||||
<input
|
||||
type="text"
|
||||
value={path}
|
||||
onChange={(e) => updateFile(path, e.target.value, content)}
|
||||
placeholder="relative/path/to/file"
|
||||
/>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => updateFile(path, path, e.target.value)}
|
||||
placeholder="File content"
|
||||
rows={3}
|
||||
/>
|
||||
<button type="button" className="danger-button" onClick={() => removeFile(path)}>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="secondary-button" onClick={addFile}>
|
||||
<Icon name="add" size="sm" />
|
||||
Add File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h4>Mounts</h4>
|
||||
{(formData.mounts || []).map((mount, index) => (
|
||||
<div key={index} className="mount-entry card">
|
||||
<div className="form-row">
|
||||
<input
|
||||
type="text"
|
||||
value={mount.target}
|
||||
onChange={(e) => updateMount(index, { target: e.target.value })}
|
||||
placeholder="/target/path"
|
||||
/>
|
||||
<select
|
||||
value={mount.mode}
|
||||
onChange={(e) =>
|
||||
updateMount(index, { mode: e.target.value as "ro" | "rw" })
|
||||
}
|
||||
>
|
||||
<option value="rw">Read/Write</option>
|
||||
<option value="ro">Read-Only</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-button"
|
||||
onClick={() => removeMount(index)}
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mount-files">
|
||||
{Object.entries(mount.files).map(([path, content]) => (
|
||||
<div key={path} className="file-entry">
|
||||
<input
|
||||
type="text"
|
||||
value={path}
|
||||
onChange={(e) =>
|
||||
updateMountFile(index, path, e.target.value, content)
|
||||
}
|
||||
placeholder="relative/path"
|
||||
/>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) =>
|
||||
updateMountFile(index, path, path, e.target.value)
|
||||
}
|
||||
placeholder="File content"
|
||||
rows={2}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-button"
|
||||
onClick={() => removeMountFile(index, path)}
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => addMountFile(index)}
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
Add File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="secondary-button" onClick={addMount}>
|
||||
<Icon name="add" size="sm" />
|
||||
Add Mount
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="primary-button">
|
||||
<Icon name="save" size="sm" />
|
||||
{editingProfile ? "Update Profile" : "Create Profile"}
|
||||
</button>
|
||||
<button type="button" className="secondary-button" onClick={resetForm}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{previewData && (
|
||||
<div className="card stack preview-panel">
|
||||
<h3>Resolved Profile Preview</h3>
|
||||
<pre>{JSON.stringify(previewData, null, 2)}</pre>
|
||||
<button className="secondary-button" onClick={() => setPreviewData(null)}>
|
||||
Close Preview
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="profiles-list">
|
||||
{profiles.length === 0 ? (
|
||||
<p className="muted">No config profiles yet. Create one above.</p>
|
||||
) : (
|
||||
profiles.map((profile) => (
|
||||
<div key={profile.id} className="profile-card card">
|
||||
<div className="profile-header">
|
||||
<div>
|
||||
<h3>{profile.name}</h3>
|
||||
{profile.description && <p className="muted">{profile.description}</p>}
|
||||
<div className="profile-meta">
|
||||
{profile.project_id && (
|
||||
<span className="badge">Project: {profile.project_id}</span>
|
||||
)}
|
||||
{profile.tool_type_id && (
|
||||
<span className="badge">Tool: {profile.tool_type_id}</span>
|
||||
)}
|
||||
{profile.is_default && <span className="badge badge-primary">Default</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => handlePreview(profile.id)}
|
||||
disabled={previewingId === profile.id}
|
||||
>
|
||||
{previewingId === profile.id ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Previewing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="info" size="sm" />
|
||||
Preview
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button className="secondary-button" onClick={() => startEdit(profile)}>
|
||||
<Icon name="edit" size="sm" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={() => handleDelete(profile.id)}
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.includes.length > 0 && (
|
||||
<div className="profile-includes">
|
||||
<span className="muted">Includes: {profile.includes.length} profile(s)</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="profile-summary">
|
||||
{Object.keys(profile.env_vars).length > 0 && (
|
||||
<span>{Object.keys(profile.env_vars).length} env vars</span>
|
||||
)}
|
||||
{Object.keys(profile.files).length > 0 && (
|
||||
<span>{Object.keys(profile.files).length} files</span>
|
||||
)}
|
||||
{profile.mounts.length > 0 && (
|
||||
<span>{profile.mounts.length} mounts</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ type SettingsStatus = "loading" | "ready" | "error";
|
||||
const TABS = [
|
||||
{ label: "General", path: "general" },
|
||||
{ label: "SSH Keys", path: "ssh-keys" },
|
||||
{ label: "Config Profiles", path: "config-profiles" },
|
||||
] as const;
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
@@ -104,7 +105,7 @@ export const SettingsPage = () => {
|
||||
<p className="eyebrow">Configuration</p>
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
<p className="muted">General preferences and SSH keys.</p>
|
||||
<p className="muted">General preferences, SSH keys, and config profiles.</p>
|
||||
</header>
|
||||
|
||||
<nav className="settings-tabs" aria-label="Settings sections">
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
|
||||
import { TerminalPage } from "./pages/terminal";
|
||||
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
||||
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||
import { ConfigProfilesPage } from "./pages/config-profiles";
|
||||
import { SessionsPage } from "./pages/sessions";
|
||||
|
||||
export const AppRouter = () => {
|
||||
@@ -40,6 +41,7 @@ export const AppRouter = () => {
|
||||
<Route index element={<Navigate to="general" replace />} />
|
||||
<Route path="general" element={<GeneralSettingsTab />} />
|
||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||
<Route path="config-profiles" element={<ConfigProfilesPage />} />
|
||||
<Route path="*" element={<Navigate to="general" replace />} />
|
||||
</Route>
|
||||
<Route path="sessions" element={<SessionsPage />} />
|
||||
|
||||
Reference in New Issue
Block a user