fix: remove legacy config APIs

Remove stale ToolConfig and ConfigFolder backend/frontend surfaces after the ConfigProfile refactor. Drop dead routers, schemas, model exports, frontend routes, clients, pages, and tests; keep ToolType API compatibility for existing interface/is_builtin response shape.

Quality gates: backend LSP diagnostics passed; backend py_compile passed; backend ruff passed; frontend ToolWorkshopPage test passed. Frontend typecheck blocked by unrelated missing xterm-addon-serialize types.
This commit is contained in:
2026-06-03 12:40:47 +02:00
parent b6f89f9df0
commit 4201326467
33 changed files with 290 additions and 2932 deletions
-284
View File
@@ -1,284 +0,0 @@
"""Config folder API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.config_folder import ConfigFolder
from src.schemas.config_folder import (
ConfigFolderCreate,
ConfigFolderUpdate,
ConfigFolderResponse,
ProjectOverrideCreate,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
@router.get("", summary="List config folders", description="Get all config folders for the current user.")
async def list_config_folders(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List config folders for the current user."""
query = select(ConfigFolder).where(ConfigFolder.user_id == user_id)
result = await session.execute(query)
folders = result.scalars().all()
return {
"folders": [
{
"id": str(f.id),
"user_id": str(f.user_id),
"name": f.name,
"description": f.description,
"mount_path": f.mount_path,
"files": f.files,
"project_overrides": f.project_overrides,
"is_active": f.is_active,
"created_at": f.created_at.isoformat() if f.created_at else None,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in folders
]
}
@router.post("", summary="Create config folder", description="Create a new config folder.", status_code=status.HTTP_201_CREATED)
async def create_config_folder(
data: ConfigFolderCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a config folder."""
# Check for duplicate name
existing = await session.scalar(
select(ConfigFolder).where(
ConfigFolder.user_id == user_id,
ConfigFolder.name == data.name,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"config folder with name '{data.name}' already exists"
)
folder = ConfigFolder(
user_id=user_id,
name=data.name,
description=data.description,
mount_path=data.mount_path,
files=data.files,
)
session.add(folder)
await session.commit()
await session.refresh(folder)
return {
"id": str(folder.id),
"user_id": str(folder.user_id),
"name": folder.name,
"description": folder.description,
"mount_path": folder.mount_path,
"files": folder.files,
"project_overrides": folder.project_overrides,
"is_active": folder.is_active,
"created_at": folder.created_at.isoformat() if folder.created_at else None,
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
}
@router.put("/{folder_id}", summary="Update config folder", description="Update an existing config folder.")
async def update_config_folder(
folder_id: uuid.UUID,
data: ConfigFolderUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a config folder."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
if data.name is not None:
folder.name = data.name
if data.description is not None:
folder.description = data.description
if data.mount_path is not None:
folder.mount_path = data.mount_path
if data.files is not None:
folder.files = data.files
if data.is_active is not None:
folder.is_active = data.is_active
await session.commit()
await session.refresh(folder)
return {
"id": str(folder.id),
"user_id": str(folder.user_id),
"name": folder.name,
"description": folder.description,
"mount_path": folder.mount_path,
"files": folder.files,
"project_overrides": folder.project_overrides,
"is_active": folder.is_active,
"created_at": folder.created_at.isoformat() if folder.created_at else None,
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
}
@router.delete("/{folder_id}", summary="Delete config folder", description="Delete a config folder.", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config_folder(
folder_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete a config folder."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
await session.delete(folder)
await session.commit()
class ProjectOverrideWithId(ProjectOverrideCreate):
project_id: uuid.UUID = Field(description="Project ID for the override")
@router.get("/{folder_id}", summary="Get config folder by ID", description="Get a single config folder by its ID.")
async def get_config_folder(
folder_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get a config folder by ID."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
return {
"id": str(folder.id),
"user_id": str(folder.user_id),
"name": folder.name,
"description": folder.description,
"mount_path": folder.mount_path,
"files": folder.files,
"project_overrides": folder.project_overrides,
"is_active": folder.is_active,
"created_at": folder.created_at.isoformat() if folder.created_at else None,
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
}
@router.post("/{folder_id}/overrides", summary="Add project override", description="Add a project override to a config folder.")
async def add_project_override(
folder_id: uuid.UUID,
data: ProjectOverrideWithId,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Add a project override to a config folder."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
# Initialize project_overrides if None
if folder.project_overrides is None:
folder.project_overrides = {}
# Add/update override
override_data = {}
if data.mount_path is not None:
override_data["mount_path"] = data.mount_path
if data.files is not None:
override_data["files"] = data.files
# Use a copy to trigger SQLAlchemy change detection on JSONB
current_overrides = dict(folder.project_overrides or {})
current_overrides[str(data.project_id)] = override_data
folder.project_overrides = current_overrides
await session.commit()
await session.refresh(folder)
return {
"id": str(folder.id),
"project_overrides": folder.project_overrides,
}
@router.put("/{folder_id}/overrides/{project_id}", summary="Update project override", description="Update a project override.")
async def update_project_override(
folder_id: uuid.UUID,
project_id: uuid.UUID,
data: ProjectOverrideCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a project override."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
# Initialize project_overrides if None
if folder.project_overrides is None:
folder.project_overrides = {}
# Update override
current_overrides = dict(folder.project_overrides or {})
override_data = current_overrides.get(str(project_id), {})
if data.mount_path is not None:
override_data["mount_path"] = data.mount_path
if data.files is not None:
override_data["files"] = data.files
current_overrides[str(project_id)] = override_data
folder.project_overrides = current_overrides
# Mark the field as modified to ensure SQLAlchemy detects the change
from sqlalchemy.orm.attributes import flag_modified
flag_modified(folder, "project_overrides")
await session.commit()
await session.refresh(folder)
return {
"id": str(folder.id),
"project_overrides": folder.project_overrides,
}
@router.delete("/{folder_id}/overrides/{project_id}", summary="Remove project override", description="Remove a project override.")
async def remove_project_override(
folder_id: uuid.UUID,
project_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Remove a project override."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
# Remove override if exists
current_overrides = dict(folder.project_overrides or {})
if str(project_id) in current_overrides:
del current_overrides[str(project_id)]
folder.project_overrides = current_overrides
await session.commit()
await session.refresh(folder)
return {
"id": str(folder.id),
"project_overrides": folder.project_overrides or {},
}
-213
View File
@@ -1,213 +0,0 @@
"""Tool configuration API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_config import ToolConfig
from src.models.tool_type import ToolType
from src.schemas.tool_config import ToolConfigCreate, ToolConfigUpdate, ToolConfigResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
@router.get("", summary="List tool configs", description="Get all tool configs for the current user.")
async def list_configs(
tool_type_id: str | None = None,
project_id: str | None = None,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list:
"""List tool configs for the current user."""
query = select(ToolConfig).where(ToolConfig.user_id == user_id)
if tool_type_id:
query = query.where(ToolConfig.tool_type_id == uuid.UUID(tool_type_id))
if project_id:
query = query.where(ToolConfig.project_id == uuid.UUID(project_id))
else:
# If no project specified, get only global configs (project_id is None)
query = query.where(ToolConfig.project_id.is_(None))
result = await session.execute(query)
configs = result.scalars().all()
return [
{
"id": str(c.id),
"tool_type_id": str(c.tool_type_id),
"project_id": str(c.project_id) if c.project_id else None,
"key": c.key,
"value": c.value,
"config_type": c.config_type,
"file_path": c.file_path,
"port_override": c.port_override,
"start_command": c.start_command,
"working_directory": c.working_directory,
"environment_variables": c.environment_variables,
"volumes": c.volumes,
}
for c in configs
]
@router.post("", summary="Create tool config", description="Create a new tool config.", status_code=status.HTTP_201_CREATED)
async def create_config(
data: ToolConfigCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a tool config."""
# Verify tool type exists
tool_type = await session.get(ToolType, uuid.UUID(data.tool_type_id))
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
# Check for existing config with same key
query = select(ToolConfig).where(
ToolConfig.user_id == user_id,
ToolConfig.tool_type_id == uuid.UUID(data.tool_type_id),
ToolConfig.key == data.key,
)
if data.project_id:
query = query.where(ToolConfig.project_id == uuid.UUID(data.project_id))
else:
query = query.where(ToolConfig.project_id.is_(None))
existing = await session.scalar(query)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"config with key '{data.key}' already exists"
)
config = ToolConfig(
user_id=user_id,
tool_type_id=uuid.UUID(data.tool_type_id),
project_id=uuid.UUID(data.project_id) if data.project_id else None,
key=data.key,
value=data.value,
config_type=data.config_type,
file_path=data.file_path,
port_override=data.port_override,
start_command=data.start_command,
working_directory=data.working_directory,
environment_variables=data.environment_variables,
volumes=data.volumes,
)
session.add(config)
await session.commit()
await session.refresh(config)
return {
"id": str(config.id),
"tool_type_id": str(config.tool_type_id),
"project_id": str(config.project_id) if config.project_id else None,
"key": config.key,
"value": config.value,
"config_type": config.config_type,
"file_path": config.file_path,
"port_override": config.port_override,
"start_command": config.start_command,
"working_directory": config.working_directory,
"environment_variables": config.environment_variables,
"volumes": config.volumes,
}
@router.put("/{config_id}", summary="Update tool config", description="Update an existing tool config.")
async def update_config(
config_id: uuid.UUID,
data: ToolConfigUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a tool config."""
config = await session.get(ToolConfig, config_id)
if config is None or config.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
if data.key is not None:
config.key = data.key
if data.value is not None:
config.value = data.value
if data.config_type is not None:
config.config_type = data.config_type
if data.file_path is not None:
config.file_path = data.file_path
if data.port_override is not None:
config.port_override = data.port_override
if data.start_command is not None:
config.start_command = data.start_command
if data.working_directory is not None:
config.working_directory = data.working_directory
if data.environment_variables is not None:
config.environment_variables = data.environment_variables
if data.volumes is not None:
config.volumes = data.volumes
await session.commit()
await session.refresh(config)
return {
"id": str(config.id),
"tool_type_id": str(config.tool_type_id),
"project_id": str(config.project_id) if config.project_id else None,
"key": config.key,
"value": config.value,
"config_type": config.config_type,
"file_path": config.file_path,
"port_override": config.port_override,
"start_command": config.start_command,
"working_directory": config.working_directory,
"environment_variables": config.environment_variables,
"volumes": config.volumes,
}
@router.get("/defaults/{tool_type_id}", summary="Get default configs", description="Get suggested default configs for a tool type.")
async def get_default_configs(
tool_type_id: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get suggested default configs for a tool type."""
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
# Return suggested defaults based on required_variables
defaults = []
for var in tool_type.required_variables:
defaults.append({
"key": var,
"value": "",
"config_type": "env",
"description": f"Required variable: {var}",
})
return {
"tool_type_id": tool_type_id,
"suggested_configs": defaults,
}
@router.delete("/{config_id}", summary="Delete tool config", description="Delete a tool config.")
async def delete_config(
config_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete a tool config."""
config = await session.get(ToolConfig, config_id)
if config is None or config.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
await session.delete(config)
await session.commit()
+65 -36
View File
@@ -1,5 +1,4 @@
import uuid
from datetime import datetime
import yaml
from fastapi import APIRouter, Depends, HTTPException, status
@@ -9,7 +8,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user, get_db_session
from src.models.tool_type import ToolType
from src.models.user import User
from src.schemas.tool_type import ToolTypeCreate, ToolTypeResponse, ToolTypeUpdate, ToolTypeValidateRequest
from src.schemas.tool_type import (
ToolTypeCreate,
ToolTypeResponse,
ToolTypeUpdate,
ToolTypeValidateRequest,
)
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
@@ -48,12 +52,15 @@ async def create_tool_type(
The newly created tool type.
"""
await _require_admin(user)
# Check for duplicate name
existing = await session.scalar(select(ToolType).where(ToolType.name == data.name))
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="tool type with this name already exists")
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="tool type with this name already exists",
)
tool_type = ToolType(
name=data.name,
display_name=data.display_name,
@@ -67,7 +74,6 @@ async def create_tool_type(
required_variables=data.required_variables,
category=data.category,
interfaces=data.interfaces,
is_builtin=False,
created_by_id=user.id,
)
session.add(tool_type)
@@ -122,7 +128,9 @@ async def get_tool_type(
"""
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
)
return tool_type
@@ -150,25 +158,30 @@ async def update_tool_type(
The updated tool type.
"""
await _require_admin(user)
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
)
if tool_type.is_builtin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot modify built-in tool types")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="cannot modify built-in tool types",
)
update_data = data.model_dump(exclude_unset=True)
# Validate port if being updated
if "default_port" in update_data:
new_port = update_data["default_port"]
if new_port <= 0 or new_port > 65535:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Port must be between 1 and 65535"
detail="Port must be between 1 and 65535",
)
# Only validate port exposure for compose definitions
definition_type = update_data.get("definition_type", tool_type.definition_type)
if definition_type == "compose":
@@ -178,28 +191,37 @@ async def update_tool_type(
parsed = yaml.safe_load(template)
except yaml.YAMLError:
parsed = None
if parsed and isinstance(parsed, dict) and "services" in parsed:
port_str = str(new_port)
port_exposed = False
for service_config in parsed["services"].values():
if isinstance(service_config, dict) and "ports" in service_config:
if (
isinstance(service_config, dict)
and "ports" in service_config
):
for port_mapping in service_config["ports"]:
if isinstance(port_mapping, str) and port_str in port_mapping:
if (
isinstance(port_mapping, str)
and port_str in port_mapping
):
port_exposed = True
break
elif isinstance(port_mapping, int) and port_mapping == new_port:
elif (
isinstance(port_mapping, int)
and port_mapping == new_port
):
port_exposed = True
break
if port_exposed:
break
if not port_exposed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Port {new_port} is not exposed in the compose template"
detail=f"Port {new_port} is not exposed in the compose template",
)
# Validate required variables for compose definitions
definition_type = update_data.get("definition_type", tool_type.definition_type)
if definition_type == "compose":
@@ -210,7 +232,7 @@ async def update_tool_type(
if placeholder not in template:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required variable '{var}' not found in compose template"
detail=f"Required variable '{var}' not found in compose template",
)
elif "required_variables" in update_data:
template = tool_type.compose_template
@@ -220,12 +242,12 @@ async def update_tool_type(
if placeholder not in template:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required variable '{var}' not found in compose template"
detail=f"Required variable '{var}' not found in compose template",
)
for field, value in update_data.items():
setattr(tool_type, field, value)
await session.commit()
await session.refresh(tool_type)
return tool_type
@@ -306,10 +328,12 @@ async def validate_tool_type(
"""
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
)
errors = []
if tool_type.definition_type == "compose":
if not tool_type.compose_template:
errors.append("Compose template is empty")
@@ -324,13 +348,13 @@ async def validate_tool_type(
errors.append("Compose template must define at least one service")
except yaml.YAMLError as e:
errors.append(f"Invalid YAML: {e}")
elif tool_type.definition_type == "dockerfile":
if not tool_type.dockerfile_template:
errors.append("Dockerfile template is empty")
elif not tool_type.dockerfile_template.strip().startswith("FROM"):
errors.append("Dockerfile must start with a FROM instruction")
return {
"valid": len(errors) == 0,
"errors": errors,
@@ -359,13 +383,18 @@ async def delete_tool_type(
None with 204 status code.
"""
await _require_admin(user)
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
)
if tool_type.is_builtin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot delete built-in tool types")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="cannot delete built-in tool types",
)
await session.delete(tool_type)
await session.commit()
+7 -4
View File
@@ -19,7 +19,6 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
@router.get(
"/me",
response_model=UserProfileResponse,
@@ -66,12 +65,16 @@ async def update_profile(
if data.name is not None:
if len(data.name.strip()) == 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty"
)
user.name = data.name.strip()
if data.email is not None:
if "@" not in data.email:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email"
)
user.email = data.email.strip()
await session.commit()
@@ -159,7 +162,7 @@ async def get_user_sessions(
id=str(inst.id),
display_name=inst.display_name,
tool_type_name=inst.tool_type.display_name if inst.tool_type else "Unknown",
tool_icon=inst.tool_type.icon if inst.tool_type else None,
tool_icon=None,
tool_type_interfaces=inst.tool_type.interfaces if inst.tool_type else [],
repository_name=inst.repository.name if inst.repository else "Unknown",
repository_id=str(inst.repository_id),
+6 -9
View File
@@ -1,4 +1,3 @@
import json
import logging
import os
@@ -15,16 +14,13 @@ from src.api.projects import router as projects_router
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
from src.api.tool_types import router as tool_types_router
from src.api.user_config import router as user_config_router
from src.api.users import router as users_router
from src.config import Settings
from src.database import SessionLocal, init_database
from src.database import init_database
from src.logging_config import (
ExceptionLoggingMiddleware,
RequestLoggingMiddleware,
@@ -67,7 +63,9 @@ def _sanitize_validation_errors(errors):
"type": error.get("type"),
"loc": error.get("loc"),
"msg": error.get("msg"),
"input": str(error.get("input")) if error.get("input") is not None else None,
"input": str(error.get("input"))
if error.get("input") is not None
else None,
}
# Convert ctx to safe format
ctx = error.get("ctx")
@@ -111,12 +109,14 @@ async def on_startup():
if not db_ready:
logger.error("Database initialization failed. Shutting down.")
import sys
sys.exit(1)
# Seed built-in data
await seed_builtin_tool_types()
logger.info("Startup complete.")
app.include_router(health_router)
app.include_router(auth_router)
app.include_router(dashboard_router)
@@ -126,11 +126,8 @@ app.include_router(ssh_keys_router)
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)
app.include_router(instance_proxy_router)
app.include_router(terminal_router)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
-2
View File
@@ -1,5 +1,4 @@
from src.models.base import Base
from src.models.config_folder import ConfigFolder
from src.models.config_include import ConfigInclude
from src.models.config_mount import ConfigMount
from src.models.config_profile import ConfigProfile
@@ -13,7 +12,6 @@ from src.models.user_config import UserConfig
__all__ = [
"Base",
"ConfigFolder",
"ConfigInclude",
"ConfigMount",
"ConfigProfile",
-33
View File
@@ -1,33 +0,0 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text
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.user import User
class ConfigFolder(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_folders"
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)
mount_path: Mapped[str] = mapped_column(String(1024), nullable=False)
files: Mapped[dict] = mapped_column(
JSON, default=dict, nullable=False
) # {"relative/path": "content", ...}
project_overrides: Mapped[dict | None] = mapped_column(
JSON, default=dict, nullable=True
) # {"project_id": {"mount_path": "...", "files": {...}}}
# DEPRECATED: Legacy auto-mounting flag. No longer used for launch-time
# auto-mounting. Use ConfigProfile and ToolInstance.selected_profile_id instead.
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
user: Mapped["User"] = relationship()
+19 -2
View File
@@ -1,8 +1,7 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text
from sqlalchemy import Uuid as UUID
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text, Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
@@ -52,3 +51,21 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
foreign_keys=[manifest_id],
)
created_by: Mapped["User | None"] = relationship()
@property
def interfaces(self) -> list[str]:
"""Backward-compatible API view for the single interface type."""
return [self.interface_type]
@interfaces.setter
def interfaces(self, value: list[str] | str) -> None:
"""Accept legacy interface lists and store the first interface type."""
if isinstance(value, str):
self.interface_type = value
return
self.interface_type = value[0] if value else "web"
@property
def is_builtin(self) -> bool:
"""Built-in tools are seeded system tools without a creating user."""
return self.created_by_id is None
-44
View File
@@ -1,44 +0,0 @@
"""Config folder request/response schemas."""
import uuid
from pydantic import BaseModel, Field
class ConfigFolderCreate(BaseModel):
name: str = Field(description="Folder name")
description: str | None = Field(default=None, description="Optional description")
mount_path: str = Field(description="Mount path in container")
files: dict[str, str] | None = Field(
default=None, description="Files as {path: content}"
)
is_active: bool = Field(default=True, description="Whether folder is active")
class ConfigFolderUpdate(BaseModel):
name: str | None = None
description: str | None = None
mount_path: str | None = None
files: dict[str, str] | None = None
is_active: bool | None = None
class ProjectOverrideCreate(BaseModel):
project_id: str = Field(description="Project ID to override for")
mount_path: str | None = Field(default=None, description="Override mount path")
files: dict[str, str] | None = Field(
default=None, description="Override files"
)
is_active: bool | None = Field(default=None, description="Override active state")
class ConfigFolderResponse(BaseModel):
id: str
user_id: str
name: str
description: str | None
mount_path: str
files: dict[str, str] | None
is_active: bool
created_at: str
updated_at: str
-47
View File
@@ -1,47 +0,0 @@
"""Tool config request/response schemas."""
from pydantic import BaseModel, Field
class ToolConfigCreate(BaseModel):
tool_type_id: str = Field(description="UUID of the tool type")
key: str = Field(description="Configuration key")
value: str = Field(description="Configuration value")
config_type: str = Field(default="env", description="Config type: env or file")
file_path: str | None = Field(default=None, description="File path for file configs")
port_override: int | None = Field(default=None, description="Port override")
start_command: str | None = Field(default=None, description="Start command override")
working_directory: str | None = Field(default=None, description="Working directory")
environment_variables: dict[str, str] | None = Field(
default=None, description="Additional environment variables"
)
volumes: list[dict] | None = Field(default=None, description="Volume mounts")
class ToolConfigUpdate(BaseModel):
value: str | None = None
config_type: str | None = None
file_path: str | None = None
port_override: int | None = None
start_command: str | None = None
working_directory: str | None = None
environment_variables: dict[str, str] | None = None
volumes: list[dict] | None = None
class ToolConfigResponse(BaseModel):
id: str
tool_type_id: str
user_id: str
project_id: str | None
key: str
value: str
config_type: str
file_path: str | None
port_override: int | None
start_command: str | None
working_directory: str | None
environment_variables: dict[str, str] | None
volumes: list[dict] | None
created_at: str
updated_at: str
+10 -9
View File
@@ -46,7 +46,7 @@ async def seed_builtin_tool_types():
"display_name": "VS Code Server",
"description": "VS Code running in the browser via code-server",
"category": "editor",
"interfaces": ["web"],
"interface_type": "web",
"compose_template": """version: "3.8"
services:
code-server:
@@ -69,7 +69,7 @@ services:
"display_name": "Jupyter Notebook",
"description": "Jupyter Lab for interactive development",
"category": "notebook",
"interfaces": ["web"],
"interface_type": "web",
"default_port": 8888,
"compose_template": """version: "3.8"
services:
@@ -90,7 +90,7 @@ services:
"display_name": "OpenCode",
"description": "AI coding assistant - run opencode in terminal",
"category": "ai-assistant",
"interfaces": ["terminal"],
"interface_type": "terminal",
"default_port": 3000,
"compose_template": """version: "3.8"
services:
@@ -129,19 +129,20 @@ volumes:
]
for tool_data in builtin_types:
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
existing = await session.scalar(
select(ToolType).where(ToolType.name == tool_data["name"])
)
if not existing:
tool_type = ToolType(
name=tool_data["name"],
display_name=tool_data["display_name"],
description=tool_data["description"],
category=tool_data["category"],
interfaces=tool_data["interfaces"],
interface_type=tool_data["interface_type"],
definition_type="compose",
compose_template=tool_data["compose_template"],
required_variables=tool_data["required_variables"],
default_port=tool_data.get("default_port"),
is_builtin=True,
default_port=tool_data["default_port"],
)
session.add(tool_type)
logger.info("Created built-in tool type: %s", tool_data["name"])
@@ -150,11 +151,11 @@ volumes:
existing.display_name = tool_data["display_name"]
existing.description = tool_data["description"]
existing.category = tool_data["category"]
existing.interfaces = tool_data["interfaces"]
existing.interface_type = tool_data["interface_type"]
existing.definition_type = "compose"
existing.compose_template = tool_data["compose_template"]
existing.required_variables = tool_data["required_variables"]
existing.default_port = tool_data.get("default_port")
existing.default_port = tool_data["default_port"]
logger.info("Updated built-in tool type: %s", tool_data["name"])
await session.commit()
+1 -2
View File
@@ -7,7 +7,7 @@ from .compose import (
write_compose_file,
write_env_file,
)
from .config_staging import write_config_files, write_config_folder_files
from .config_staging import write_config_files
from .container import (
connect_container_to_network,
find_free_port,
@@ -30,7 +30,6 @@ __all__ = [
"write_env_file",
"execute_compose_command",
"write_config_files",
"write_config_folder_files",
"get_container_id",
"get_container_name",
"connect_container_to_network",
+1 -58
View File
@@ -1,11 +1,7 @@
"""Config folder file staging for Docker instances."""
"""Config file staging for Docker instances."""
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
"""Write config files to the instance directory.
@@ -24,56 +20,3 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
full_path.parent.mkdir(parents=True, exist_ok=True)
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
+84 -86
View File
@@ -11,14 +11,11 @@ from datetime import datetime
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.config_folder import ConfigFolder
from src.models.config_profile import ConfigProfile
from src.models.git_repository import GitRepository
from src.models.project import Project
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
@@ -54,7 +51,8 @@ async def create_new_instance(
instance = ToolInstance(
name=instance_name,
display_name=display_name or f"{project.name} / {repo.name} / {tool_type.display_name}",
display_name=display_name
or f"{project.name} / {repo.name} / {tool_type.display_name}",
tool_type_id=tool_type.id,
repository_id=repo.id,
project_id=project.id,
@@ -85,16 +83,27 @@ async def start_existing_instance(
instance.status = "building"
await session.commit()
env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs(
session, user.id, instance.tool_type_id, project_id
)
env_vars: dict[str, str] = {}
config_files: dict[str, str] = {}
port_override = None
start_command = None
working_directory = None
extra_volumes: list[dict] = []
selected_profile = None
if instance.selected_profile_id:
selected_profile = await session.get(ConfigProfile, instance.selected_profile_id)
selected_profile = await session.get(
ConfigProfile, instance.selected_profile_id
)
if selected_profile and selected_profile.user_id == user.id:
instance_dir = os.path.dirname(instance.compose_path)
env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile(
(
env_vars,
port_override,
start_command,
working_directory,
extra_volumes,
) = await compose_svc._apply_resolved_profile(
selected_profile,
instance_dir,
env_vars,
@@ -104,14 +113,17 @@ async def start_existing_instance(
extra_volumes,
)
env_file_path, extra_volumes = await _stage_configs_and_folders(
session, user.id, project_id, os.path.dirname(instance.compose_path),
env_vars, config_files, extra_volumes
env_file_path, extra_volumes = await _stage_configs(
os.path.dirname(instance.compose_path), env_vars, config_files, extra_volumes
)
if port_override or start_command or working_directory or extra_volumes:
compose_svc._modify_compose_file(
instance.compose_path, port_override, start_command, working_directory, extra_volumes
instance.compose_path,
port_override,
start_command,
working_directory,
extra_volumes,
)
returncode, _stdout, stderr = compose_svc.execute_compose_command(
@@ -138,6 +150,14 @@ async def start_existing_instance(
await session.commit()
tool_type = await session.get(ToolType, instance.tool_type_id)
if not tool_type:
instance.status = "error"
await session.commit()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="tool type not found for instance",
)
success, probe_logs = await _run_readiness_probe(instance, tool_type)
if not success:
instance.status = "failed"
@@ -175,27 +195,45 @@ async def restart_existing_instance(
await session.commit()
return {"status": instance.status}
env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs(
session, user.id, instance.tool_type_id, project_id
)
env_vars: dict[str, str] = {}
config_files: dict[str, str] = {}
port_override = None
start_command = None
working_directory = None
extra_volumes: list[dict] = []
stored_profile = None
if instance.selected_profile_id:
stored_profile = await session.get(ConfigProfile, instance.selected_profile_id)
if stored_profile and stored_profile.user_id == user.id:
instance_dir = os.path.dirname(instance.compose_path)
env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile(
stored_profile, instance_dir, env_vars, port_override, start_command, working_directory, extra_volumes
(
env_vars,
port_override,
start_command,
working_directory,
extra_volumes,
) = await compose_svc._apply_resolved_profile(
stored_profile,
instance_dir,
env_vars,
port_override,
start_command,
working_directory,
extra_volumes,
)
env_file_path, extra_volumes = await _stage_configs_and_folders(
session, user.id, project_id, os.path.dirname(instance.compose_path),
env_vars, config_files, extra_volumes
env_file_path, extra_volumes = await _stage_configs(
os.path.dirname(instance.compose_path), env_vars, config_files, extra_volumes
)
if port_override or start_command or working_directory or extra_volumes:
compose_svc._modify_compose_file(
instance.compose_path, port_override, start_command, working_directory, extra_volumes
instance.compose_path,
port_override,
start_command,
working_directory,
extra_volumes,
)
returncode, _stdout, _stderr = compose_svc.execute_compose_command(
@@ -210,6 +248,11 @@ async def restart_existing_instance(
instance.last_started_at = datetime.now()
tool_type = await session.get(ToolType, instance.tool_type_id)
if not tool_type:
instance.status = "error"
await session.commit()
return {"status": instance.status}
await _start_tunnel_if_web(instance, tool_type)
await session.commit()
@@ -235,7 +278,9 @@ async def stop_existing_instance(session: AsyncSession, instance: ToolInstance)
await session.commit()
async def delete_existing_instance(session: AsyncSession, instance: ToolInstance) -> None:
async def delete_existing_instance(
session: AsyncSession, instance: ToolInstance
) -> None:
"""Delete an instance, its containers, and its directory."""
if instance.tunnel_id:
try:
@@ -255,6 +300,7 @@ async def delete_existing_instance(session: AsyncSession, instance: ToolInstance
# ── Internal helpers ───────────────────────────────────────────────────────
async def _build_or_render_compose(
tool_type: ToolType,
instance_name: str,
@@ -283,11 +329,11 @@ async def _build_or_render_compose(
compose_content = (
f'version: "3.8"\nservices:\n app:\n'
f' image: {image_tag}\n'
f' container_name: {instance_name}\n'
f" image: {image_tag}\n"
f" container_name: {instance_name}\n"
f' ports:\n - "{tool_port}:{tool_type.default_port}"\n'
f' volumes:\n - {repo.path}:/workspace\n'
f' restart: unless-stopped\n'
f" volumes:\n - {repo.path}:/workspace\n"
f" restart: unless-stopped\n"
)
else:
variables = {
@@ -299,6 +345,11 @@ async def _build_or_render_compose(
"USER_ID": str(user.id),
"PROJECT_ID": str(project_id),
}
if not tool_type.compose_template:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="tool type has no compose template",
)
compose_content = compose_svc.render_compose_template(
tool_type.compose_template, variables
)
@@ -307,80 +358,25 @@ async def _build_or_render_compose(
return os.path.join(instance_dir, "docker-compose.yml")
async def _fetch_tool_configs(
session: AsyncSession,
user_id: Any,
tool_type_id: Any,
project_id: Any,
) -> tuple[dict, dict, Any, Any, Any, dict, list]:
"""Fetch tool configs and return parsed values."""
env_vars: dict[str, str] = {}
config_files: dict[str, str] = {}
port_override = None
start_command = None
working_directory = None
extra_env_vars: dict[str, str] = {}
extra_volumes: list[dict] = []
query = (
select(ToolConfig)
.where(ToolConfig.user_id == user_id, ToolConfig.tool_type_id == tool_type_id)
.where((ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None)))
)
configs = (await session.execute(query)).scalars().all()
for cfg in configs:
if cfg.config_type == "env":
env_vars[cfg.key] = cfg.value
elif cfg.config_type == "file" and cfg.file_path:
config_files[cfg.file_path] = cfg.value
if cfg.port_override:
port_override = cfg.port_override
if cfg.start_command:
start_command = cfg.start_command
if cfg.working_directory:
working_directory = cfg.working_directory
if cfg.environment_variables:
extra_env_vars.update(cfg.environment_variables)
if cfg.volumes:
extra_volumes.extend(cfg.volumes)
env_vars.update(extra_env_vars)
return env_vars, config_files, port_override, start_command, working_directory, extra_env_vars, extra_volumes
async def _stage_configs_and_folders(
session: AsyncSession,
user_id: Any,
project_id: Any,
async def _stage_configs(
instance_dir: str,
env_vars: dict[str, str],
config_files: dict[str, str],
extra_volumes: list[dict],
) -> tuple[str | None, list[dict]]:
"""Write env/config files and config folders."""
"""Write env/config files for the resolved profile."""
env_file_path: str | None = None
if env_vars:
env_file_path = compose_svc.write_env_file(instance_dir, env_vars)
if config_files:
config_staging.write_config_files(instance_dir, config_files)
folder_query = select(ConfigFolder).where(
ConfigFolder.user_id == user_id, ConfigFolder.is_active.is_(True)
)
folders = (await session.execute(folder_query)).scalars().all()
if folders:
folder_volumes = config_staging.write_config_folder_files(
instance_dir, folders, str(project_id)
)
extra_volumes.extend(folder_volumes)
return env_file_path, extra_volumes
async def _start_tunnel_if_web(instance: ToolInstance, tool_type: ToolType) -> None:
"""Create Cloudflare tunnel for web-enabled tools."""
if "web" not in tool_type.interfaces or not tool_type.default_port:
if tool_type.interface_type != "web" or not tool_type.default_port:
instance.url = None
instance.public_url = None
return
@@ -393,7 +389,9 @@ async def _start_tunnel_if_web(instance: ToolInstance, tool_type: ToolType) -> N
instance.tunnel_id = tunnel_info["pid"]
instance.public_url = tunnel_info["url"]
instance.url = tunnel_info["url"]
logger.info("Created tunnel for instance %s: %s", instance.id, tunnel_info["url"])
logger.info(
"Created tunnel for instance %s: %s", instance.id, tunnel_info["url"]
)
except Exception as exc:
logger.error("Failed to create tunnel for instance %s: %s", instance.id, exc)
instance.status = "error"