diff --git a/apps/api/alembic/versions/398082499c30_add_tool_config_fields.py b/apps/api/alembic/versions/398082499c30_add_tool_config_fields.py new file mode 100644 index 0000000..a9b1312 --- /dev/null +++ b/apps/api/alembic/versions/398082499c30_add_tool_config_fields.py @@ -0,0 +1,40 @@ +"""add_tool_config_fields + +Revision ID: 398082499c30 +Revises: af8512103d67 +Create Date: 2026-05-22 18:38:20.166184 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '398082499c30' +down_revision = 'af8512103d67' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add new columns to tool_configs + op.add_column('tool_configs', sa.Column('port_override', sa.Integer(), nullable=True)) + op.add_column('tool_configs', sa.Column('start_command', sa.Text(), nullable=True)) + op.add_column('tool_configs', sa.Column('working_directory', sa.Text(), nullable=True)) + op.add_column('tool_configs', sa.Column('environment_variables', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}')) + op.add_column('tool_configs', sa.Column('volumes', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='[]')) + + # Add CHECK constraint for port range + op.create_check_constraint('chk_port_range', 'tool_configs', sa.text('port_override IS NULL OR (port_override >= 1 AND port_override <= 65535)')) + + +def downgrade() -> None: + # Drop CHECK constraint + op.drop_constraint('chk_port_range', 'tool_configs', type_='check') + + # Drop columns + op.drop_column('tool_configs', 'port_override') + op.drop_column('tool_configs', 'start_command') + op.drop_column('tool_configs', 'working_directory') + op.drop_column('tool_configs', 'environment_variables') + op.drop_column('tool_configs', 'volumes') diff --git a/apps/api/alembic/versions/8ed7dd80973d_create_config_folders_table.py b/apps/api/alembic/versions/8ed7dd80973d_create_config_folders_table.py new file mode 100644 index 0000000..7c3e8a8 --- /dev/null +++ b/apps/api/alembic/versions/8ed7dd80973d_create_config_folders_table.py @@ -0,0 +1,44 @@ +"""create_config_folders_table + +Revision ID: 8ed7dd80973d +Revises: 398082499c30 +Create Date: 2026-05-22 18:38:22.133696 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '8ed7dd80973d' +down_revision = '398082499c30' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'config_folders', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), + 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('mount_path', sa.String(1024), nullable=False), + sa.Column('files', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'), + sa.Column('project_overrides', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('NOW()')), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('NOW()')), + sa.UniqueConstraint('user_id', 'name', name='uq_config_folders_user_name') + ) + + # Add index on user_id for filtering + op.create_index('idx_config_folders_user', 'config_folders', ['user_id']) + + +def downgrade() -> None: + # Drop index + op.drop_index('idx_config_folders_user', table_name='config_folders') + + # Drop table + op.drop_table('config_folders') diff --git a/apps/api/alembic/versions/af8512103d67_add_tool_type_fields.py b/apps/api/alembic/versions/af8512103d67_add_tool_type_fields.py new file mode 100644 index 0000000..19f0bfe --- /dev/null +++ b/apps/api/alembic/versions/af8512103d67_add_tool_type_fields.py @@ -0,0 +1,38 @@ +"""add_tool_type_fields + +Revision ID: af8512103d67 +Revises: 0012_default_port_req +Create Date: 2026-05-22 18:37:56.607240 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'af8512103d67' +down_revision = '0012_default_port_req' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add new columns to tool_types + op.add_column('tool_types', sa.Column('definition_type', sa.String(20), nullable=False, server_default='compose')) + op.add_column('tool_types', sa.Column('dockerfile_template', sa.Text(), nullable=True)) + op.add_column('tool_types', sa.Column('build_context', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}')) + op.add_column('tool_types', sa.Column('readiness_probe', postgresql.JSONB(astext_type=sa.Text()), nullable=True)) + + # Add CHECK constraint for definition_type + op.create_check_constraint('chk_definition_type', 'tool_types', sa.text("definition_type IN ('compose', 'dockerfile')")) + + +def downgrade() -> None: + # Drop CHECK constraint + op.drop_constraint('chk_definition_type', 'tool_types', type_='check') + + # Drop columns + op.drop_column('tool_types', 'definition_type') + op.drop_column('tool_types', 'dockerfile_template') + op.drop_column('tool_types', 'build_context') + op.drop_column('tool_types', 'readiness_probe') diff --git a/apps/api/src/api/config_folders.py b/apps/api/src/api/config_folders.py new file mode 100644 index 0000000..ce72afd --- /dev/null +++ b/apps/api/src/api/config_folders.py @@ -0,0 +1,327 @@ +"""Config folder API endpoints.""" + +import logging +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field, field_validator +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 + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/config-folders", tags=["config-folders"]) + +MAX_FOLDER_SIZE_MB = 10 +MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024 + + +class ConfigFolderCreate(BaseModel): + name: str = Field(description="Folder name (unique per user)") + description: str | None = Field(default=None, description="Optional description") + mount_path: str = Field(description="Default mount path in container") + files: dict = Field(default_factory=dict, description="Files as {path: content}") + + @field_validator("mount_path") + @classmethod + def validate_mount_path(cls, v: str) -> str: + if not v.startswith("/"): + raise ValueError("Mount path must be absolute (start with /)") + return v + + @field_validator("files") + @classmethod + def validate_files(cls, v: dict) -> dict: + total_size = 0 + for path, content in v.items(): + # Check for path traversal + if ".." in path or path.startswith("/"): + raise ValueError(f"Invalid file path: {path}") + total_size += len(content.encode("utf-8")) + + if total_size > MAX_FOLDER_SIZE_BYTES: + raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit") + + return v + + +class ConfigFolderUpdate(BaseModel): + name: str | None = Field(default=None, description="Folder name") + description: str | None = Field(default=None, description="Optional description") + mount_path: str | None = Field(default=None, description="Default mount path") + files: dict | None = Field(default=None, description="Files as {path: content}") + is_active: bool | None = Field(default=None, description="Active/inactive toggle") + + @field_validator("mount_path") + @classmethod + def validate_mount_path(cls, v: str | None) -> str | None: + if v is None: + return v + if not v.startswith("/"): + raise ValueError("Mount path must be absolute (start with /)") + return v + + @field_validator("files") + @classmethod + def validate_files(cls, v: dict | None) -> dict | None: + if v is None: + return v + + total_size = 0 + for path, content in v.items(): + # Check for path traversal + if ".." in path or path.startswith("/"): + raise ValueError(f"Invalid file path: {path}") + total_size += len(content.encode("utf-8")) + + if total_size > MAX_FOLDER_SIZE_BYTES: + raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit") + + return v + + +class ProjectOverrideCreate(BaseModel): + mount_path: str | None = Field(default=None, description="Override mount path") + files: dict = Field(default_factory=dict, description="Override files") + + @field_validator("mount_path") + @classmethod + def validate_mount_path(cls, v: str | None) -> str | None: + if v is None: + return v + if not v.startswith("/"): + raise ValueError("Mount path must be absolute (start with /)") + return v + + +class ConfigFolderResponse(BaseModel): + id: str + user_id: str + name: str + description: str | None + mount_path: str + files: dict + project_overrides: dict | None + is_active: bool + created_at: str + updated_at: str + + +@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.") +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.") +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() + + +@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, + project_id: uuid.UUID, + data: ProjectOverrideCreate, + 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 + + folder.project_overrides[str(project_id)] = override_data + + 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 + override_data = folder.project_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 + + folder.project_overrides[str(project_id)] = override_data + + 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 + if folder.project_overrides and str(project_id) in folder.project_overrides: + del folder.project_overrides[str(project_id)] + await session.commit() diff --git a/apps/api/src/api/tool_configs.py b/apps/api/src/api/tool_configs.py index 0124f84..7a50afa 100644 --- a/apps/api/src/api/tool_configs.py +++ b/apps/api/src/api/tool_configs.py @@ -24,6 +24,45 @@ class ToolConfigCreate(BaseModel): value: str = Field(description="Config value") config_type: str = Field(default="env", description="Type: env or file") file_path: str | None = Field(default=None, description="File path for file-type configs") + port_override: int | None = Field(default=None, description="Port override (1-65535)") + start_command: str | None = Field(default=None, description="Override container start command") + working_directory: str | None = Field(default=None, description="Working directory inside container") + environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object") + volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array") + + @field_validator("port_override") + @classmethod + def validate_port(cls, v: int | None) -> int | None: + if v is None: + return v + if v < 1 or v > 65535: + raise ValueError("Port must be between 1 and 65535") + return v + + @field_validator("environment_variables") + @classmethod + def validate_env_vars(cls, v: dict | None) -> dict | None: + if v is None: + return v + if not isinstance(v, dict): + raise ValueError("environment_variables must be a JSON object") + return v + + @field_validator("volumes") + @classmethod + def validate_volumes(cls, v: list | None) -> list | None: + if v is None: + return v + if not isinstance(v, list): + raise ValueError("volumes must be a JSON array") + for i, vol in enumerate(v): + if not isinstance(vol, dict): + raise ValueError(f"Volume at index {i} must be an object") + if "source" not in vol: + raise ValueError(f"Volume at index {i} must have 'source' field") + if "target" not in vol: + raise ValueError(f"Volume at index {i} must have 'target' field") + return v class ToolConfigResponse(BaseModel): @@ -34,6 +73,11 @@ class ToolConfigResponse(BaseModel): value: str config_type: str file_path: str | None + port_override: int | None + start_command: str | None + working_directory: str | None + environment_variables: dict | None + volumes: list[dict] | None @router.get("", summary="List tool configs", description="Get all tool configs for the current user.") @@ -67,6 +111,11 @@ async def list_configs( "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 ] @@ -111,6 +160,11 @@ async def create_config( 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() @@ -124,6 +178,11 @@ async def create_config( "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, } @@ -143,6 +202,11 @@ async def update_config( config.value = data.value config.config_type = data.config_type config.file_path = data.file_path + config.port_override = data.port_override + config.start_command = data.start_command + config.working_directory = data.working_directory + config.environment_variables = data.environment_variables + config.volumes = data.volumes await session.commit() await session.refresh(config) @@ -155,6 +219,38 @@ async def update_config( "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, + "defaults": defaults, } diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 36693a8..65150f3 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -22,6 +22,7 @@ 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, @@ -37,7 +38,10 @@ from src.services.docker import ( write_compose_file, write_config_files, write_env_file, + write_config_folder_files, ) +from src.services.docker_build import build_image +from src.services.readiness_probe import execute_probe router = APIRouter(prefix="/projects", tags=["tool-instances"]) @@ -51,6 +55,58 @@ class CreateInstanceRequest(BaseModel): display_name: str | None = Field(default=None, description="Optional display name for the instance") +def _modify_compose_file( + compose_path: str, + port_override: int | None = None, + start_command: str | None = None, + working_directory: str | None = None, + extra_volumes: list[dict] | None = None, +) -> None: + """Modify compose file with runtime overrides.""" + import yaml + from pathlib import Path + + compose_file = Path(compose_path) + content = compose_file.read_text() + compose_data = yaml.safe_load(content) + + if not compose_data or "services" not in compose_data: + return + + # Apply modifications to the first service + for service_name, service_config in compose_data["services"].items(): + if port_override and "ports" in service_config: + # Update port mapping + for i, port_mapping in enumerate(service_config["ports"]): + if isinstance(port_mapping, str) and ":" in port_mapping: + host_port, container_port = port_mapping.split(":", 1) + service_config["ports"][i] = f"{port_override}:{container_port}" + break + + if start_command: + service_config["command"] = start_command + + if working_directory: + service_config["working_dir"] = working_directory + + if extra_volumes: + if "volumes" not in service_config: + service_config["volumes"] = [] + for vol in extra_volumes: + source = vol.get("source", "") + target = vol.get("target", "") + vol_type = vol.get("type", "bind") + if vol_type == "bind": + service_config["volumes"].append(f"{source}:{target}") + else: + service_config["volumes"].append(f"{source}:{target}:{vol_type}") + + break # Only modify the first service + + # Write back + compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) + + async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User: """Fetch a user by ID or raise 404 if not found.""" user = await session.get(User, user_id) @@ -145,18 +201,55 @@ async def create_instance( # Find free port tool_port = find_free_port() - # Render compose template - variables = { - "REPO_PATH": repo.path, - "INSTANCE_NAME": instance_name, - "INSTANCE_ID": instance_name, - "TOOL_NAME": instance_name, - "TOOL_PORT": tool_port, - "USER_ID": str(user_id), - "PROJECT_ID": str(project_id), - } - compose_content = render_compose_template(tool_type.compose_template, variables) - write_compose_file(instance_dir, compose_content) + # Handle based on definition type + if tool_type.definition_type == "dockerfile": + # Build image from Dockerfile + image_tag = f"headquarter/{instance_name}:latest" + + if tool_type.dockerfile_template: + returncode, stdout, stderr = build_image( + instance_dir=instance_dir, + dockerfile=tool_type.dockerfile_template, + tag=image_tag, + build_context=tool_type.build_context, + ) + + if returncode != 0: + logger.error("Failed to build image for instance %s: %s", instance_name, stderr) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to build Docker image: {stderr[:500]}", + ) + + logger.info("Successfully built image %s for instance %s", image_tag, instance_name) + + # Generate compose for dockerfile-built image + compose_content = f"""version: "3.8" +services: + app: + image: {image_tag} + container_name: {instance_name} + ports: + - "{tool_port}:{tool_type.default_port}" + volumes: + - {repo.path}:/workspace + restart: unless-stopped +""" + write_compose_file(instance_dir, compose_content) + + else: + # Render compose template + variables = { + "REPO_PATH": repo.path, + "INSTANCE_NAME": instance_name, + "INSTANCE_ID": instance_name, + "TOOL_NAME": instance_name, + "TOOL_PORT": tool_port, + "USER_ID": str(user_id), + "PROJECT_ID": str(project_id), + } + compose_content = render_compose_template(tool_type.compose_template, variables) + write_compose_file(instance_dir, compose_content) # Create database record instance = ToolInstance( @@ -353,6 +446,11 @@ async def start_instance( # Fetch tool configs for this tool type env_vars = {} config_files = {} + port_override = None + start_command = None + working_directory = None + extra_env_vars = {} + extra_volumes = [] config_query = select(ToolConfig).where( ToolConfig.user_id == user_id, @@ -370,6 +468,30 @@ async def start_instance( env_vars[config.key] = config.value elif config.config_type == "file" and config.file_path: config_files[config.file_path] = config.value + + # Handle new config fields + if config.port_override: + port_override = config.port_override + if config.start_command: + start_command = config.start_command + if config.working_directory: + working_directory = config.working_directory + if config.environment_variables: + extra_env_vars.update(config.environment_variables) + if config.volumes: + extra_volumes.extend(config.volumes) + + # 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) # Write env file and config files instance_dir = os.path.dirname(instance.compose_path) @@ -382,6 +504,17 @@ async def start_instance( if config_files: 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) + + # Modify compose file if needed (port override, start command, working dir, volumes) + if port_override or start_command or working_directory or extra_volumes: + _modify_compose_file(instance.compose_path, port_override, start_command, working_directory, extra_volumes) + logger.info("Modified compose file for instance %s", instance.id) # Execute docker compose up with env file logger.info("Running docker compose up for instance %s (compose_path=%s)", instance.id, instance.compose_path) @@ -419,9 +552,48 @@ async def start_instance( else: logger.warning("Failed to connect %s to backend network", container_name) - instance.status = "running" + instance.status = "starting" instance.last_started_at = datetime.now() await session.commit() + logger.info("Instance %s container is running, checking readiness", instance.id) + + # Execute readiness probe if configured + tool_type = await session.get(ToolType, instance.tool_type_id) + if tool_type and tool_type.readiness_probe: + probe_config = tool_type.readiness_probe + probe_command = probe_config.get("command", "") + probe_timeout = probe_config.get("timeout", 30) + probe_interval = probe_config.get("interval", 2) + + if probe_command and instance.container_id: + logger.info( + "Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d", + instance.id, probe_command, probe_timeout, probe_interval + ) + + success, probe_logs = await execute_probe( + container_id=instance.container_id, + command=probe_command, + timeout=probe_timeout, + interval=probe_interval, + ) + + if not success: + instance.status = "failed" + instance.url = None + instance.public_url = None + await session.commit() + logger.error("Readiness probe failed for instance %s: %s", instance.id, "\n".join(probe_logs)) + return { + "status": "failed", + "error": f"Readiness probe failed after {probe_timeout}s", + "probe_logs": probe_logs, + } + + logger.info("Readiness probe succeeded for instance %s", instance.id) + + instance.status = "running" + await session.commit() logger.info("Instance %s is now running", instance.id) # Get tool type for default port diff --git a/apps/api/src/api/tool_types.py b/apps/api/src/api/tool_types.py index fa11971..d9478f4 100644 --- a/apps/api/src/api/tool_types.py +++ b/apps/api/src/api/tool_types.py @@ -38,14 +38,32 @@ class ToolTypeCreate(BaseModel): display_name: str description: str | None = None default_port: int - compose_template: str + definition_type: str = "compose" + compose_template: str | None = None + dockerfile_template: str | None = None + build_context: dict | None = None + readiness_probe: dict | None = None required_variables: list[str] = [] category: str = "other" interfaces: list[str] = ["web"] + @field_validator("definition_type") + @classmethod + def validate_definition_type(cls, v: str) -> str: + if v not in ("compose", "dockerfile"): + raise ValueError("definition_type must be 'compose' or 'dockerfile'") + return v + @field_validator("compose_template") @classmethod - def validate_compose_template(cls, v: str) -> str: + def validate_compose_template(cls, v: str | None, info) -> str | None: + data = info.data + if data.get("definition_type") != "compose": + return v + + if v is None: + raise ValueError("compose_template is required when definition_type is 'compose'") + try: parsed = yaml.safe_load(v) except yaml.YAMLError as e: @@ -62,6 +80,21 @@ class ToolTypeCreate(BaseModel): return v + @field_validator("dockerfile_template") + @classmethod + def validate_dockerfile_template(cls, v: str | None, info) -> str | None: + data = info.data + if data.get("definition_type") != "dockerfile": + return v + + if v is None: + raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'") + + if not v.strip().startswith("FROM"): + raise ValueError("Dockerfile must start with a FROM instruction") + + return v + @field_validator("default_port") @classmethod def validate_default_port(cls, v: int, info) -> int: @@ -70,10 +103,13 @@ class ToolTypeCreate(BaseModel): # Get compose_template from the model data data = info.data - if "compose_template" not in data: + if data.get("definition_type") != "compose": + return v + + template = data.get("compose_template") + if not template: return v - template = data["compose_template"] try: parsed = yaml.safe_load(template) except yaml.YAMLError: @@ -109,12 +145,14 @@ class ToolTypeCreate(BaseModel): if not v: return v - # Get compose_template from the model data data = info.data - if "compose_template" not in data: + if data.get("definition_type") != "compose": + return v + + template = data.get("compose_template") + if not template: return v - template = data["compose_template"] for var in v: placeholder = f"{{{{{var}}}}}" if placeholder not in template: @@ -127,17 +165,35 @@ class ToolTypeUpdate(BaseModel): display_name: str | None = None description: str | None = None default_port: int | None = None + definition_type: str | None = None compose_template: str | None = None + dockerfile_template: str | None = None + build_context: dict | None = None + readiness_probe: dict | None = None required_variables: list[str] | None = None category: str | None = None interfaces: list[str] | None = None + @field_validator("definition_type") + @classmethod + def validate_definition_type(cls, v: str | None) -> str | None: + if v is None: + return v + if v not in ("compose", "dockerfile"): + raise ValueError("definition_type must be 'compose' or 'dockerfile'") + return v + @field_validator("compose_template") @classmethod - def validate_compose_template(cls, v: str | None) -> str | None: + def validate_compose_template(cls, v: str | None, info) -> str | None: if v is None: return v + data = info.data + definition_type = data.get("definition_type") + if definition_type and definition_type != "compose": + return v + try: parsed = yaml.safe_load(v) except yaml.YAMLError as e: @@ -154,6 +210,22 @@ class ToolTypeUpdate(BaseModel): return v + @field_validator("dockerfile_template") + @classmethod + def validate_dockerfile_template(cls, v: str | None, info) -> str | None: + if v is None: + return v + + data = info.data + definition_type = data.get("definition_type") + if definition_type and definition_type != "dockerfile": + return v + + if not v.strip().startswith("FROM"): + raise ValueError("Dockerfile must start with a FROM instruction") + + return v + class ToolTypeResponse(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -165,7 +237,11 @@ class ToolTypeResponse(BaseModel): category: str interfaces: list[str] default_port: int - compose_template: str + definition_type: str + compose_template: str | None + dockerfile_template: str | None + build_context: dict | None + readiness_probe: dict | None required_variables: list[str] is_builtin: bool created_by_id: uuid.UUID | None @@ -208,7 +284,11 @@ async def create_tool_type( display_name=data.display_name, description=data.description, default_port=data.default_port, + definition_type=data.definition_type, compose_template=data.compose_template, + dockerfile_template=data.dockerfile_template, + build_context=data.build_context, + readiness_probe=data.readiness_probe, required_variables=data.required_variables, is_builtin=False, created_by_id=user.id, @@ -315,54 +395,59 @@ async def update_tool_type( detail="Port must be between 1 and 65535" ) - # Check if port is exposed in compose template - template = update_data.get("compose_template", tool_type.compose_template) - try: - 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: - for port_mapping in service_config["ports"]: - if isinstance(port_mapping, str) and port_str in port_mapping: - port_exposed = True + # Only validate port exposure for compose definitions + definition_type = update_data.get("definition_type", tool_type.definition_type) + if definition_type == "compose": + template = update_data.get("compose_template", tool_type.compose_template) + if template: + try: + 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: + for port_mapping in service_config["ports"]: + 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: + port_exposed = True + break + if port_exposed: break - 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" - ) + + 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" + ) - # Validate required variables if both are being updated - if "required_variables" in update_data and "compose_template" in update_data: - template = update_data["compose_template"] - for var in update_data["required_variables"]: - placeholder = f"{{{{{var}}}}}" - if placeholder not in template: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Required variable '{var}' not found in compose template" - ) - elif "required_variables" in update_data: - # Only updating variables, check against existing template - template = tool_type.compose_template - for var in update_data["required_variables"]: - placeholder = f"{{{{{var}}}}}" - if placeholder not in template: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Required variable '{var}' not found in compose template" - ) + # Validate required variables for compose definitions + definition_type = update_data.get("definition_type", tool_type.definition_type) + if definition_type == "compose": + if "required_variables" in update_data and "compose_template" in update_data: + template = update_data["compose_template"] + for var in update_data["required_variables"]: + placeholder = f"{{{{{var}}}}}" + if placeholder not in template: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Required variable '{var}' not found in compose template" + ) + elif "required_variables" in update_data: + template = tool_type.compose_template + if template: + for var in update_data["required_variables"]: + placeholder = f"{{{{{var}}}}}" + if placeholder not in template: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Required variable '{var}' not found in compose template" + ) for field, value in update_data.items(): setattr(tool_type, field, value) @@ -372,6 +457,60 @@ async def update_tool_type( return tool_type +@router.get( + "/{tool_type_id}/validate", + summary="Validate tool type", + description="Validate the compose template or dockerfile syntax of a tool type.", +) +async def validate_tool_type( + tool_type_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Validate a tool type's template syntax. + + Args: + tool_type_id: UUID of the tool type to validate. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Validation result with success status and any errors. + """ + await _get_user(session, user_id) + 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") + + errors = [] + + if tool_type.definition_type == "compose": + if not tool_type.compose_template: + errors.append("Compose template is empty") + else: + try: + parsed = yaml.safe_load(tool_type.compose_template) + if not isinstance(parsed, dict): + errors.append("Compose template must be a YAML mapping") + elif "services" not in parsed: + errors.append("Compose template must contain 'services' key") + elif not parsed["services"]: + 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, + } + + @router.delete( "/{tool_type_id}", status_code=status.HTTP_204_NO_CONTENT, diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 7382785..7cab4ea 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -16,6 +16,7 @@ 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.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 @@ -199,6 +200,7 @@ volumes: description=tool_data["description"], category=tool_data["category"], interfaces=tool_data["interfaces"], + definition_type="compose", compose_template=tool_data["compose_template"], required_variables=tool_data["required_variables"], default_port=tool_data.get("default_port"), @@ -212,6 +214,7 @@ volumes: existing.description = tool_data["description"] existing.category = tool_data["category"] existing.interfaces = tool_data["interfaces"] + 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") @@ -245,6 +248,7 @@ 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(tool_instances_router) app.include_router(tool_configs_router) app.include_router(sessions_router) diff --git a/apps/api/src/models/__init__.py b/apps/api/src/models/__init__.py index c7a573b..38599b6 100644 --- a/apps/api/src/models/__init__.py +++ b/apps/api/src/models/__init__.py @@ -1,4 +1,5 @@ from src.models.base import Base +from src.models.config_folder import ConfigFolder from src.models.git_repository import GitRepository from src.models.project import Project from src.models.ssh_key import SSHKey @@ -7,4 +8,4 @@ from src.models.tool_type import ToolType from src.models.user import User from src.models.user_config import UserConfig -__all__ = ["Base", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"] +__all__ = ["Base", "ConfigFolder", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"] diff --git a/apps/api/src/models/config_folder.py b/apps/api/src/models/config_folder.py new file mode 100644 index 0000000..0ca2499 --- /dev/null +++ b/apps/api/src/models/config_folder.py @@ -0,0 +1,31 @@ +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": {...}}} + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + user: Mapped["User"] = relationship() diff --git a/apps/api/src/models/tool_config.py b/apps/api/src/models/tool_config.py index cf403d9..2d7e824 100644 --- a/apps/api/src/models/tool_config.py +++ b/apps/api/src/models/tool_config.py @@ -33,6 +33,15 @@ class ToolConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base): file_path: Mapped[str | None] = mapped_column( String(1024), nullable=True ) # Only for file type + port_override: Mapped[int | None] = mapped_column(nullable=True) + start_command: Mapped[str | None] = mapped_column(Text, nullable=True) + working_directory: Mapped[str | None] = mapped_column(Text, nullable=True) + environment_variables: Mapped[dict | None] = mapped_column( + JSON, default=dict, nullable=True + ) + volumes: Mapped[list[dict] | None] = mapped_column( + JSON, default=list, nullable=True + ) user: Mapped["User"] = relationship() tool_type: Mapped["ToolType"] = relationship() diff --git a/apps/api/src/models/tool_type.py b/apps/api/src/models/tool_type.py index f346312..57e41ec 100644 --- a/apps/api/src/models/tool_type.py +++ b/apps/api/src/models/tool_type.py @@ -20,7 +20,15 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base): category: Mapped[str] = mapped_column(String(50), nullable=False, default="other") interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) default_port: Mapped[int] = mapped_column(nullable=False) - compose_template: Mapped[str] = mapped_column(Text, nullable=False) + definition_type: Mapped[str] = mapped_column( + String(20), nullable=False, default="compose" + ) # "compose" or "dockerfile" + compose_template: Mapped[str | None] = mapped_column(Text, nullable=True) + dockerfile_template: Mapped[str | None] = mapped_column(Text, nullable=True) + build_context: Mapped[dict | None] = mapped_column( + JSON, default=dict, nullable=True + ) + readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True) required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) created_by_id: Mapped[uuid.UUID | None] = mapped_column( diff --git a/apps/api/src/services/docker.py b/apps/api/src/services/docker.py index 2416c6a..bb01fee 100644 --- a/apps/api/src/services/docker.py +++ b/apps/api/src/services/docker.py @@ -92,6 +92,59 @@ 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]: diff --git a/apps/api/src/services/docker_build.py b/apps/api/src/services/docker_build.py new file mode 100644 index 0000000..efd1eb8 --- /dev/null +++ b/apps/api/src/services/docker_build.py @@ -0,0 +1,69 @@ +"""Docker build service for building images from Dockerfiles.""" + +import logging +import subprocess + +logger = logging.getLogger(__name__) + + +def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dict | None = None) -> tuple[int, str, str]: + """Build a Docker image from a Dockerfile. + + Args: + instance_dir: Directory containing the Dockerfile + dockerfile: Dockerfile content + tag: Image tag to apply + build_context: Optional build context files {path: content} + + Returns: + Tuple of (returncode, stdout, stderr) + """ + import os + from pathlib import Path + + # Write Dockerfile + dockerfile_path = Path(instance_dir) / "Dockerfile" + dockerfile_path.write_text(dockerfile) + logger.info("Wrote Dockerfile to %s", dockerfile_path) + + # Write build context files + if build_context: + for file_path, content in build_context.items(): + full_path = Path(instance_dir) / file_path + # Security: ensure path doesn't escape instance_dir + try: + full_path.resolve().relative_to(Path(instance_dir).resolve()) + except ValueError: + logger.error("Build context file path escapes instance directory: %s", file_path) + raise ValueError(f"Build context file path '{file_path}' escapes instance directory") + + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(content) + logger.info("Wrote build context file: %s", full_path) + + # Build image + logger.info("Building Docker image with tag: %s", tag) + cmd = [ + "docker", "build", + "-t", tag, + "-f", str(dockerfile_path), + instance_dir, + ] + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=300, # 5 minute timeout for builds + ) + logger.info("Docker build completed: returncode=%d", result.returncode) + if result.returncode != 0: + logger.error("Docker build failed: %s", result.stderr[:1000]) + return result.returncode, result.stdout, result.stderr + except subprocess.TimeoutExpired: + logger.error("Docker build timed out after 300 seconds") + return 1, "", "Build timed out after 300 seconds" + except Exception as exc: + logger.exception("Docker build failed: %s", exc) + return 1, "", str(exc) diff --git a/apps/api/src/services/readiness_probe.py b/apps/api/src/services/readiness_probe.py new file mode 100644 index 0000000..119eda3 --- /dev/null +++ b/apps/api/src/services/readiness_probe.py @@ -0,0 +1,66 @@ +"""Readiness probe service for checking if containers are ready.""" + +import asyncio +import logging +import subprocess + +logger = logging.getLogger(__name__) + + +async def execute_probe( + container_id: str, + command: str, + timeout: int = 30, + interval: int = 2, +) -> tuple[bool, list[str]]: + """Execute a readiness probe command inside a container. + + Args: + container_id: Docker container ID or name + command: Command to execute inside the container + timeout: Maximum total time to wait (seconds) + interval: Time between retries (seconds) + + Returns: + Tuple of (success, logs) + """ + logs = [] + start_time = asyncio.get_event_loop().time() + attempt = 0 + + while True: + attempt += 1 + elapsed = asyncio.get_event_loop().time() - start_time + + if elapsed >= timeout: + logs.append(f"Probe timed out after {timeout}s ({attempt} attempts)") + return False, logs + + try: + logger.debug("Probe attempt %d: %s", attempt, command) + + # Execute command inside container + result = subprocess.run( + ["docker", "exec", container_id, "sh", "-c", command], + capture_output=True, + text=True, + timeout=interval, # Each attempt has its own timeout + ) + + if result.returncode == 0: + logs.append(f"Attempt {attempt}: Success") + if result.stdout: + logs.append(f"Output: {result.stdout.strip()}") + return True, logs + else: + logs.append(f"Attempt {attempt}: Failed (exit code {result.returncode})") + if result.stderr: + logs.append(f"Stderr: {result.stderr.strip()[:200]}") + + except subprocess.TimeoutExpired: + logs.append(f"Attempt {attempt}: Command timed out") + except Exception as exc: + logs.append(f"Attempt {attempt}: Error - {exc}") + + # Wait before next attempt + await asyncio.sleep(interval) diff --git a/apps/web/src/api/config_folders.ts b/apps/web/src/api/config_folders.ts new file mode 100644 index 0000000..0fd7245 --- /dev/null +++ b/apps/web/src/api/config_folders.ts @@ -0,0 +1,95 @@ +import { apiClient } from "./client"; + +export interface ConfigFolder { + id: string; + user_id: string; + name: string; + description: string | null; + mount_path: string; + files: Record; + project_overrides: Record }> | null; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface CreateConfigFolderRequest { + name: string; + description?: string; + mount_path: string; + files?: Record; + is_active?: boolean; +} + +export interface UpdateConfigFolderRequest { + name?: string; + description?: string; + mount_path?: string; + files?: Record; + is_active?: boolean; +} + +export interface ProjectOverrideRequest { + mount_path?: string; + files?: Record; +} + +export const listConfigFolders = async (): Promise => { + const response = await apiClient.get("/config-folders"); + return response.data; +}; + +export const getConfigFolder = async (id: string): Promise => { + const response = await apiClient.get(`/config-folders/${id}`); + return response.data; +}; + +export const createConfigFolder = async ( + data: CreateConfigFolderRequest +): Promise => { + const response = await apiClient.post("/config-folders", data); + return response.data; +}; + +export const updateConfigFolder = async ( + id: string, + data: UpdateConfigFolderRequest +): Promise => { + const response = await apiClient.put(`/config-folders/${id}`, data); + return response.data; +}; + +export const deleteConfigFolder = async (id: string): Promise => { + await apiClient.delete(`/config-folders/${id}`); +}; + +export const addProjectOverride = async ( + id: string, + projectId: string, + data: ProjectOverrideRequest +): Promise => { + const response = await apiClient.post( + `/config-folders/${id}/overrides/${projectId}`, + data + ); + return response.data; +}; + +export const updateProjectOverride = async ( + id: string, + projectId: string, + data: ProjectOverrideRequest +): Promise => { + const response = await apiClient.put( + `/config-folders/${id}/overrides/${projectId}`, + data + ); + return response.data; +}; + +export const deleteProjectOverride = async ( + id: string, + projectId: string +): Promise => { + await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`); +}; diff --git a/apps/web/src/api/tool_configs.ts b/apps/web/src/api/tool_configs.ts index 52c750c..5d1d2c7 100644 --- a/apps/web/src/api/tool_configs.ts +++ b/apps/web/src/api/tool_configs.ts @@ -8,6 +8,11 @@ export interface ToolConfig { value: string; config_type: string; file_path: string | null; + port_override: number | null; + start_command: string | null; + working_directory: string | null; + environment_variables: Record | null; + volumes: Array<{ source: string; target: string; type?: string }> | null; } export interface CreateToolConfigRequest { @@ -17,6 +22,11 @@ export interface CreateToolConfigRequest { value: string; config_type?: string; file_path?: string; + port_override?: number; + start_command?: string; + working_directory?: string; + environment_variables?: Record; + volumes?: Array<{ source: string; target: string; type?: string }>; } export const listToolConfigs = async ( @@ -53,4 +63,13 @@ export const updateToolConfig = async ( export const deleteToolConfig = async (id: string): Promise => { await apiClient.delete(`/tool-configs/${id}`); +}; + +export const getToolConfigDefaults = async ( + toolTypeId: string +): Promise => { + const response = await apiClient.get( + `/tool-configs/defaults/${toolTypeId}` + ); + return response.data; }; \ No newline at end of file diff --git a/apps/web/src/api/tool_types.ts b/apps/web/src/api/tool_types.ts index 32ccee6..b566033 100644 --- a/apps/web/src/api/tool_types.ts +++ b/apps/web/src/api/tool_types.ts @@ -1,5 +1,11 @@ import { apiClient } from "./client"; +export interface ReadinessProbe { + command: string; + timeout: number; + interval: number; +} + export interface ToolType { id: string; name: string; @@ -8,7 +14,11 @@ export interface ToolType { category: string; interfaces: string[]; default_port: number | null; - compose_template: string; + definition_type: 'compose' | 'dockerfile'; + compose_template: string | null; + dockerfile_template: string | null; + build_context: Record | null; + readiness_probe: ReadinessProbe | null; required_variables: string[]; is_builtin: boolean; created_by_id: string | null; @@ -23,7 +33,11 @@ export interface CreateToolTypeRequest { category?: string; interfaces?: string[]; default_port: number; - compose_template: string; + definition_type?: 'compose' | 'dockerfile'; + compose_template?: string; + dockerfile_template?: string; + build_context?: Record; + readiness_probe?: ReadinessProbe; required_variables: string[]; } @@ -33,7 +47,11 @@ export interface UpdateToolTypeRequest { category?: string; interfaces?: string[]; default_port?: number; + definition_type?: 'compose' | 'dockerfile'; compose_template?: string; + dockerfile_template?: string; + build_context?: Record; + readiness_probe?: ReadinessProbe; required_variables?: string[]; } @@ -60,3 +78,8 @@ export const updateToolType = async (id: string, data: UpdateToolTypeRequest): P export const deleteToolType = async (id: string): Promise => { await apiClient.delete(`/tool-types/${id}`); }; + +export const validateToolType = async (id: string): Promise<{ valid: boolean; errors?: string[] }> => { + const response = await apiClient.get<{ valid: boolean; errors?: string[] }>(`/tool-types/${id}/validate`); + return response.data; +}; diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index 4eae2b1..3e2f3f8 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -14,8 +14,7 @@ const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [ { to: "/sessions", label: "Sessions", icon: "terminal" }, { to: "/projects", label: "Projects", icon: "projects" }, { to: "/ssh-keys", label: "SSH Keys", icon: "profile" }, - { to: "/tool-types", label: "Tool Types", icon: "code" }, - { to: "/tool-configs", label: "Tool Configs", icon: "settings" }, + { to: "/tool-workshop", label: "Tool Workshop", icon: "settings" }, { to: "/settings", label: "Settings", icon: "settings" } ]; diff --git a/apps/web/src/pages/tool-types.tsx b/apps/web/src/pages/tool-types.tsx index 88898ca..272b0a6 100644 --- a/apps/web/src/pages/tool-types.tsx +++ b/apps/web/src/pages/tool-types.tsx @@ -67,7 +67,7 @@ export const ToolTypesPage = () => { setFormCategory(toolType.category ?? ""); setFormInterfaces(toolType.interfaces ?? []); setFormPort(toolType.default_port?.toString() ?? ""); - setFormTemplate(toolType.compose_template); + setFormTemplate(toolType.compose_template ?? ""); setFormVariables(toolType.required_variables.join(", ")); setFormError(null); setEditingToolType(toolType); diff --git a/apps/web/src/pages/tool-workshop.tsx b/apps/web/src/pages/tool-workshop.tsx new file mode 100644 index 0000000..2468579 --- /dev/null +++ b/apps/web/src/pages/tool-workshop.tsx @@ -0,0 +1,1004 @@ +import { useCallback, useEffect, useState } from "react"; +import { Icon } from "../components/icon"; +import { + createToolType, + deleteToolType, + listToolTypes, + updateToolType, + validateToolType, + type CreateToolTypeRequest, + type ReadinessProbe, + type ToolType, + type UpdateToolTypeRequest, +} from "../api/tool_types"; +import { + createToolConfig, + deleteToolConfig, + getToolConfigDefaults, + listToolConfigs, + updateToolConfig, + type CreateToolConfigRequest, + type ToolConfig, +} from "../api/tool_configs"; +import { + createConfigFolder, + deleteConfigFolder, + listConfigFolders, + updateConfigFolder, + type ConfigFolder, + type CreateConfigFolderRequest, + type UpdateConfigFolderRequest, +} from "../api/config_folders"; + +type Tab = "types" | "configs" | "folders"; +type Status = "loading" | "ready" | "error"; + +export const ToolWorkshopPage = () => { + const [activeTab, setActiveTab] = useState("types"); + const [status, setStatus] = useState("loading"); + const [toolTypes, setToolTypes] = useState([]); + const [configs, setConfigs] = useState([]); + const [folders, setFolders] = useState([]); + + // Tool Type state + const [selectedToolType, setSelectedToolType] = useState(null); + const [toolTypeForm, setToolTypeForm] = useState({ + name: "", + display_name: "", + description: "", + category: "", + interfaces: [] as string[], + default_port: "", + definition_type: "compose" as "compose" | "dockerfile", + compose_template: "", + dockerfile_template: "", + readiness_command: "", + readiness_timeout: "30", + readiness_interval: "2", + required_variables: "", + }); + const [toolTypeError, setToolTypeError] = useState(null); + const [showToolTypeForm, setShowToolTypeForm] = useState(false); + + // Config state + const [selectedConfig, setSelectedConfig] = useState(null); + const [configForm, setConfigForm] = useState({ + tool_type_id: "", + key: "", + value: "", + config_type: "env", + file_path: "", + port_override: "", + start_command: "", + working_directory: "", + env_vars_json: "{}", + volumes_json: "[]", + }); + const [configError, setConfigError] = useState(null); + const [showConfigForm, setShowConfigForm] = useState(false); + + // Folder state + const [selectedFolder, setSelectedFolder] = useState(null); + const [folderForm, setFolderForm] = useState({ + name: "", + description: "", + mount_path: "/home/user", + files_json: "{}", + is_active: true, + }); + const [folderError, setFolderError] = useState(null); + const [showFolderForm, setShowFolderForm] = useState(false); + + const loadData = useCallback(async () => { + setStatus("loading"); + try { + const [types, cfgs, fldrs] = await Promise.all([ + listToolTypes(), + listToolConfigs(), + listConfigFolders(), + ]); + setToolTypes(types); + setConfigs(cfgs); + setFolders(fldrs); + setStatus("ready"); + } catch { + setStatus("error"); + } + }, []); + + useEffect(() => { + void loadData(); + }, [loadData]); + + // Tool Type handlers + const openCreateToolType = () => { + setToolTypeForm({ + name: "", + display_name: "", + description: "", + category: "", + interfaces: [], + default_port: "", + definition_type: "compose", + compose_template: "", + dockerfile_template: "", + readiness_command: "", + readiness_timeout: "30", + readiness_interval: "2", + required_variables: "", + }); + setToolTypeError(null); + setShowToolTypeForm(true); + setSelectedToolType(null); + }; + + const openEditToolType = (toolType: ToolType) => { + setToolTypeForm({ + name: toolType.name, + display_name: toolType.display_name, + description: toolType.description || "", + category: toolType.category || "", + interfaces: toolType.interfaces || [], + default_port: toolType.default_port?.toString() || "", + definition_type: toolType.definition_type || "compose", + compose_template: toolType.compose_template || "", + dockerfile_template: toolType.dockerfile_template || "", + readiness_command: toolType.readiness_probe?.command || "", + readiness_timeout: toolType.readiness_probe?.timeout?.toString() || "30", + readiness_interval: toolType.readiness_probe?.interval?.toString() || "2", + required_variables: toolType.required_variables?.join(", ") || "", + }); + setToolTypeError(null); + setShowToolTypeForm(true); + setSelectedToolType(toolType); + }; + + const handleToolTypeSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setToolTypeError(null); + + if (!toolTypeForm.name.trim() || !toolTypeForm.display_name.trim()) { + setToolTypeError("Name and display name are required"); + return; + } + + if (!toolTypeForm.default_port.trim() || isNaN(Number(toolTypeForm.default_port))) { + setToolTypeError("Default port is required and must be a number"); + return; + } + + const template = toolTypeForm.definition_type === "compose" + ? toolTypeForm.compose_template + : toolTypeForm.dockerfile_template; + + if (!template.trim()) { + setToolTypeError(`${toolTypeForm.definition_type === "compose" ? "Compose" : "Dockerfile"} template is required`); + return; + } + + const variables = toolTypeForm.required_variables + .split(",") + .map((v) => v.trim()) + .filter((v) => v.length > 0); + + const readinessProbe: ReadinessProbe | undefined = toolTypeForm.readiness_command.trim() + ? { + command: toolTypeForm.readiness_command.trim(), + timeout: parseInt(toolTypeForm.readiness_timeout) || 30, + interval: parseInt(toolTypeForm.readiness_interval) || 2, + } + : undefined; + + try { + if (selectedToolType) { + const input: UpdateToolTypeRequest = { + display_name: toolTypeForm.display_name.trim(), + description: toolTypeForm.description.trim() || undefined, + category: toolTypeForm.category.trim() || undefined, + interfaces: toolTypeForm.interfaces.length > 0 ? toolTypeForm.interfaces : undefined, + default_port: Number(toolTypeForm.default_port), + definition_type: toolTypeForm.definition_type, + compose_template: toolTypeForm.definition_type === "compose" ? template : undefined, + dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined, + readiness_probe: readinessProbe, + required_variables: variables, + }; + await updateToolType(selectedToolType.id, input); + } else { + const input: CreateToolTypeRequest = { + name: toolTypeForm.name.trim(), + display_name: toolTypeForm.display_name.trim(), + description: toolTypeForm.description.trim() || undefined, + category: toolTypeForm.category.trim() || undefined, + interfaces: toolTypeForm.interfaces.length > 0 ? toolTypeForm.interfaces : undefined, + default_port: Number(toolTypeForm.default_port), + definition_type: toolTypeForm.definition_type, + compose_template: toolTypeForm.definition_type === "compose" ? template : undefined, + dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined, + readiness_probe: readinessProbe, + required_variables: variables, + }; + await createToolType(input); + } + setShowToolTypeForm(false); + setSelectedToolType(null); + await loadData(); + } catch (err) { + const axiosError = err as { response?: { data?: { detail?: string } } }; + setToolTypeError(axiosError?.response?.data?.detail || "Failed to save tool type"); + } + }; + + const handleDeleteToolType = async (id: string) => { + if (!window.confirm("Delete this tool type? All associated configs will be removed.")) return; + try { + await deleteToolType(id); + await loadData(); + } catch { + alert("Failed to delete tool type"); + } + }; + + // Config handlers + const openCreateConfig = () => { + setConfigForm({ + tool_type_id: toolTypes[0]?.id || "", + key: "", + value: "", + config_type: "env", + file_path: "", + port_override: "", + start_command: "", + working_directory: "", + env_vars_json: "{}", + volumes_json: "[]", + }); + setConfigError(null); + setShowConfigForm(true); + setSelectedConfig(null); + }; + + const openEditConfig = (config: ToolConfig) => { + setConfigForm({ + tool_type_id: config.tool_type_id, + key: config.key, + value: config.value, + config_type: config.config_type, + file_path: config.file_path || "", + port_override: config.port_override?.toString() || "", + start_command: config.start_command || "", + working_directory: config.working_directory || "", + env_vars_json: config.environment_variables ? JSON.stringify(config.environment_variables, null, 2) : "{}", + volumes_json: config.volumes ? JSON.stringify(config.volumes, null, 2) : "[]", + }); + setConfigError(null); + setShowConfigForm(true); + setSelectedConfig(config); + }; + + const handleConfigSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setConfigError(null); + + if (!configForm.tool_type_id || !configForm.key.trim()) { + setConfigError("Tool type and key are required"); + return; + } + + // Validate JSON fields + let envVars: Record | undefined; + let volumes: Array<{ source: string; target: string; type?: string }> | undefined; + + try { + if (configForm.env_vars_json.trim() && configForm.env_vars_json.trim() !== "{}") { + envVars = JSON.parse(configForm.env_vars_json); + } + } catch { + setConfigError("Environment variables must be valid JSON"); + return; + } + + try { + if (configForm.volumes_json.trim() && configForm.volumes_json.trim() !== "[]") { + volumes = JSON.parse(configForm.volumes_json); + } + } catch { + setConfigError("Volumes must be valid JSON array"); + return; + } + + const data: CreateToolConfigRequest = { + tool_type_id: configForm.tool_type_id, + key: configForm.key.trim(), + value: configForm.value, + config_type: configForm.config_type, + file_path: configForm.config_type === "file" ? configForm.file_path : undefined, + port_override: configForm.port_override ? Number(configForm.port_override) : undefined, + start_command: configForm.start_command.trim() || undefined, + working_directory: configForm.working_directory.trim() || undefined, + environment_variables: envVars, + volumes, + }; + + try { + if (selectedConfig) { + await updateToolConfig(selectedConfig.id, data); + } else { + await createToolConfig(data); + } + setShowConfigForm(false); + setSelectedConfig(null); + await loadData(); + } catch (err) { + const axiosError = err as { response?: { data?: { detail?: string } } }; + setConfigError(axiosError?.response?.data?.detail || "Failed to save config"); + } + }; + + const handleDeleteConfig = async (id: string) => { + if (!window.confirm("Delete this config?")) return; + try { + await deleteToolConfig(id); + await loadData(); + } catch { + alert("Failed to delete config"); + } + }; + + // Folder handlers + const openCreateFolder = () => { + setFolderForm({ + name: "", + description: "", + mount_path: "/home/user", + files_json: "{}", + is_active: true, + }); + setFolderError(null); + setShowFolderForm(true); + setSelectedFolder(null); + }; + + const openEditFolder = (folder: ConfigFolder) => { + setFolderForm({ + name: folder.name, + description: folder.description || "", + mount_path: folder.mount_path, + files_json: JSON.stringify(folder.files, null, 2), + is_active: folder.is_active, + }); + setFolderError(null); + setShowFolderForm(true); + setSelectedFolder(folder); + }; + + const handleFolderSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setFolderError(null); + + if (!folderForm.name.trim() || !folderForm.mount_path.trim()) { + setFolderError("Name and mount path are required"); + return; + } + + let files: Record | undefined; + try { + if (folderForm.files_json.trim() && folderForm.files_json.trim() !== "{}") { + files = JSON.parse(folderForm.files_json); + } + } catch { + setFolderError("Files must be valid JSON object"); + return; + } + + const data: CreateConfigFolderRequest | UpdateConfigFolderRequest = { + name: folderForm.name.trim(), + description: folderForm.description.trim() || undefined, + mount_path: folderForm.mount_path.trim(), + files, + is_active: folderForm.is_active, + }; + + try { + if (selectedFolder) { + await updateConfigFolder(selectedFolder.id, data); + } else { + await createConfigFolder(data as CreateConfigFolderRequest); + } + setShowFolderForm(false); + setSelectedFolder(null); + await loadData(); + } catch (err) { + const axiosError = err as { response?: { data?: { detail?: string } } }; + setFolderError(axiosError?.response?.data?.detail || "Failed to save folder"); + } + }; + + const handleDeleteFolder = async (id: string) => { + if (!window.confirm("Delete this config folder?")) return; + try { + await deleteConfigFolder(id); + await loadData(); + } catch { + alert("Failed to delete folder"); + } + }; + + if (status === "loading") { + return ( +
+

Loading Tool Workshop...

+
+ ); + } + + if (status === "error") { + return ( +
+

Failed to load Tool Workshop.

+ +
+ ); + } + + return ( +
+
+

Tool Workshop

+
+ + {/* Tabs */} +
+ {(["types", "configs", "folders"] as Tab[]).map((tab) => ( + + ))} +
+ + {/* Tool Types Tab */} + {activeTab === "types" && ( +
+
+

Tool Types

+ +
+ + {showToolTypeForm && ( +
+

{selectedToolType ? "Edit" : "Create"} Tool Type

+
+
+ + +
+ +
+ + setToolTypeForm({ ...toolTypeForm, name: e.target.value })} + disabled={!!selectedToolType} + placeholder="e.g., code-server" + className="form-input" + required + /> +
+ +
+ + setToolTypeForm({ ...toolTypeForm, display_name: e.target.value })} + placeholder="e.g., VS Code Server" + className="form-input" + required + /> +
+ +
+ + setToolTypeForm({ ...toolTypeForm, description: e.target.value })} + placeholder="Optional description" + className="form-input" + /> +
+ +
+ + setToolTypeForm({ ...toolTypeForm, category: e.target.value })} + placeholder="e.g., editor, notebook, ai-assistant" + className="form-input" + /> +
+ +
+ +
+ {["web", "terminal"].map((iface) => ( + + ))} +
+
+ +
+ + setToolTypeForm({ ...toolTypeForm, default_port: e.target.value })} + placeholder="e.g., 8443" + className="form-input" + required + /> +
+ +
+ +