Merge commit 'ea174b1'

# Conflicts:
#	apps/web/src/pages/projects.test.tsx
This commit is contained in:
2026-05-24 15:03:58 +00:00
71 changed files with 11597 additions and 380 deletions
@@ -0,0 +1,104 @@
"""add config profiles, includes, mounts, and tool instance profile selection
Revision ID: 0013_add_config_profiles
Revises: 0012_default_port_req
Create Date: 2026-05-24 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0013_add_config_profiles"
down_revision: Union[str, None] = "0012_default_port_req"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create config_profiles table
op.create_table(
"config_profiles",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
)
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
# Create config_includes table
op.create_table(
"config_includes",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["included_profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
)
op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"])
op.create_index("idx_config_includes_included", "config_includes", ["included_profile_id"])
# Create config_mounts table
op.create_table(
"config_mounts",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("mount_path", sa.String(length=1024), nullable=False),
sa.Column("content", sa.Text(), nullable=True),
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["source_profile_id"], ["config_profiles.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"])
# Add selected_profile_id to tool_instances
op.add_column(
"tool_instances",
sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_tool_instances_selected_profile",
"tool_instances",
"config_profiles",
["selected_profile_id"],
["id"],
ondelete="SET NULL",
)
op.create_index("idx_tool_instances_selected_profile", "tool_instances", ["selected_profile_id"])
def downgrade() -> None:
# Remove selected_profile_id from tool_instances
op.drop_index("idx_tool_instances_selected_profile", table_name="tool_instances")
op.drop_constraint("fk_tool_instances_selected_profile", "tool_instances", type_="foreignkey")
op.drop_column("tool_instances", "selected_profile_id")
# Drop config_mounts
op.drop_index("idx_config_mounts_profile", table_name="config_mounts")
op.drop_table("config_mounts")
# Drop config_includes
op.drop_index("idx_config_includes_included", table_name="config_includes")
op.drop_index("idx_config_includes_profile", table_name="config_includes")
op.drop_table("config_includes")
# Drop config_profiles
op.drop_index("idx_config_profiles_user", table_name="config_profiles")
op.drop_table("config_profiles")
@@ -0,0 +1,119 @@
"""add profile resolver fields to config profiles and mounts
Revision ID: 0014_add_profile_resolver_fields
Revises: 0013_add_config_profiles
Create Date: 2026-05-24 14:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0014_add_profile_resolver_fields"
down_revision: Union[str, None] = "0013_add_config_profiles"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add fields to config_profiles
op.add_column(
"config_profiles",
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("environment_variables", sa.JSON(), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("start_command", sa.Text(), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("working_directory", sa.Text(), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("port", sa.Integer(), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
)
# Add foreign keys for project and tool_type
op.create_foreign_key(
"fk_config_profiles_project",
"config_profiles",
"projects",
["project_id"],
["id"],
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_config_profiles_tool_type",
"config_profiles",
"tool_types",
["tool_type_id"],
["id"],
ondelete="CASCADE",
)
# Create indices
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
op.create_index("idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"])
# Alter config_mounts: rename mount_path to target_path, add mode, change content to files JSON
op.alter_column("config_mounts", "mount_path", new_column_name="target_path")
op.add_column(
"config_mounts",
sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"),
)
op.add_column(
"config_mounts",
sa.Column("files", sa.JSON(), nullable=True),
)
# Drop the source_profile foreign key if it exists
op.drop_constraint(
"config_mounts_source_profile_id_fkey",
"config_mounts",
type_="foreignkey",
)
op.drop_column("config_mounts", "content")
op.drop_column("config_mounts", "source_profile_id")
def downgrade() -> None:
# Restore config_mounts
op.add_column(
"config_mounts",
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"config_mounts",
sa.Column("content", sa.Text(), nullable=True),
)
op.drop_column("config_mounts", "files")
op.drop_column("config_mounts", "mode")
op.alter_column("config_mounts", "target_path", new_column_name="mount_path")
# Restore config_profiles
op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles")
op.drop_index("idx_config_profiles_project", table_name="config_profiles")
op.drop_constraint("fk_config_profiles_tool_type", "config_profiles", type_="foreignkey")
op.drop_constraint("fk_config_profiles_project", "config_profiles", type_="foreignkey")
op.drop_column("config_profiles", "is_default")
op.drop_column("config_profiles", "port")
op.drop_column("config_profiles", "working_directory")
op.drop_column("config_profiles", "start_command")
op.drop_column("config_profiles", "environment_variables")
op.drop_column("config_profiles", "tool_type_id")
op.drop_column("config_profiles", "project_id")
@@ -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')
@@ -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')
@@ -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')
+372
View File
@@ -0,0 +1,372 @@
"""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.", 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 {},
}
+877
View File
@@ -0,0 +1,877 @@
"""Config profile API endpoints."""
import logging
import uuid
from typing import Any
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 sqlalchemy.orm import selectinload
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.config_include import ConfigInclude
from src.models.config_mount import ConfigMount
from src.models.config_profile import ConfigProfile
from src.models.tool_type import ToolType
from src.models.user_config import UserConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
MAX_MOUNT_PATH_LENGTH = 1024
MAX_CONTENT_LENGTH = 1024 * 1024 # 1MB
MAX_INCLUDES_DEPTH = 10
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class ConfigProfileCreate(BaseModel):
name: str = Field(description="Profile name (unique per user)")
description: str | None = Field(default=None, description="Optional description")
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Profile name cannot be empty")
if len(v) > 255:
raise ValueError("Profile name must be 255 characters or less")
return v
class ConfigProfileUpdate(BaseModel):
name: str | None = Field(default=None, description="Profile name")
description: str | None = Field(default=None, description="Optional description")
@field_validator("name")
@classmethod
def validate_name(cls, v: str | None) -> str | None:
if v is None:
return v
v = v.strip()
if not v:
raise ValueError("Profile name cannot be empty")
if len(v) > 255:
raise ValueError("Profile name must be 255 characters or less")
return v
class ConfigProfileResponse(BaseModel):
id: str
user_id: str
name: str
description: str | None
created_at: str
updated_at: str
class ConfigProfileDetailResponse(ConfigProfileResponse):
includes: list[dict[str, Any]]
mounts: list[dict[str, Any]]
class ConfigIncludeCreate(BaseModel):
included_profile_id: str = Field(description="UUID of the profile to include")
order_index: int = Field(default=0, description="Order index for include resolution")
class ConfigIncludeUpdate(BaseModel):
order_index: int = Field(description="Order index for include resolution")
class ConfigIncludeResponse(BaseModel):
id: str
profile_id: str
included_profile_id: str
included_profile_name: str | None
order_index: int
created_at: str
updated_at: str
class ConfigMountCreate(BaseModel):
target_path: str = Field(description="Absolute target path in container")
mode: str = Field(default="rw", description="Mount mode (rw or ro)")
files: dict[str, str] | None = Field(default=None, description="Files as {path: content}")
order_index: int = Field(default=0, description="Order index for mount resolution")
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("Target path must be absolute (start with /)")
if ".." in v:
raise ValueError("Target path cannot contain parent directory references (..)")
if len(v) > MAX_MOUNT_PATH_LENGTH:
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
return v
class ConfigMountUpdate(BaseModel):
target_path: str | None = Field(default=None, description="Absolute target path in container")
mode: str | None = Field(default=None, description="Mount mode (rw or ro)")
files: dict[str, str] | None = Field(default=None, description="Files as {path: content}")
order_index: int | None = Field(default=None, description="Order index for mount resolution")
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str | None) -> str | None:
if v is None:
return v
if not v.startswith("/"):
raise ValueError("Target path must be absolute (start with /)")
if ".." in v:
raise ValueError("Target path cannot contain parent directory references (..)")
if len(v) > MAX_MOUNT_PATH_LENGTH:
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
return v
class ConfigMountResponse(BaseModel):
id: str
profile_id: str
target_path: str
mode: str
files: dict[str, str] | None
order_index: int
created_at: str
updated_at: str
class DefaultProfilesUpdate(BaseModel):
default_profiles: dict[str, str] = Field(description="Mapping of tool_type_id to profile_id")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _get_owned_profile(
profile_id: uuid.UUID,
user_id: uuid.UUID,
session: AsyncSession,
) -> ConfigProfile:
"""Fetch a config profile and verify ownership."""
profile = await session.get(ConfigProfile, profile_id)
if profile is None or profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="config profile not found",
)
return profile
async def _detect_cycle(
session: AsyncSession,
profile_id: uuid.UUID,
visited: set[uuid.UUID] | None = None,
depth: int = 0,
) -> bool:
"""Detect cycles in profile includes using DFS.
Returns True if a cycle is detected.
"""
if depth > MAX_INCLUDES_DEPTH:
return True
if visited is None:
visited = set()
if profile_id in visited:
return True
visited.add(profile_id)
result = await session.execute(
select(ConfigInclude.included_profile_id).where(
ConfigInclude.profile_id == profile_id
)
)
included_ids = result.scalars().all()
for included_id in included_ids:
if await _detect_cycle(session, included_id, visited.copy(), depth + 1):
return True
return False
async def _validate_includes_no_cycle(
session: AsyncSession,
profile_id: uuid.UUID,
new_included_id: uuid.UUID | None = None,
) -> None:
"""Validate that adding an include wouldn't create a cycle."""
if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="adding this include would create a circular reference",
)
# ---------------------------------------------------------------------------
# Profile CRUD
# ---------------------------------------------------------------------------
@router.get(
"",
summary="List config profiles",
description="Get all config profiles for the current user. Optionally filter by tool type compatibility.",
)
async def list_config_profiles(
tool_type_id: str | None = None,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List config profiles for the current user."""
query = select(ConfigProfile).where(ConfigProfile.user_id == user_id)
# If tool_type_id is provided, filter to compatible profiles
# For now, all profiles are considered compatible with all tool types
# since there's no explicit compatibility matrix. Future enhancement:
# could filter by profile tags or mount path patterns.
if tool_type_id:
# Validate the tool type exists
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",
)
# All profiles are compatible; just return user's profiles
pass
result = await session.execute(query.order_by(ConfigProfile.name))
profiles = result.scalars().all()
return {
"profiles": [
{
"id": str(p.id),
"user_id": str(p.user_id),
"name": p.name,
"description": p.description,
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
}
for p in profiles
]
}
@router.post(
"",
summary="Create config profile",
description="Create a new config profile.",
status_code=status.HTTP_201_CREATED,
)
async def create_config_profile(
data: ConfigProfileCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a config profile."""
# Check for duplicate name
existing = await session.scalar(
select(ConfigProfile).where(
ConfigProfile.user_id == user_id,
ConfigProfile.name == data.name,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"config profile with name '{data.name}' already exists",
)
profile = ConfigProfile(
user_id=user_id,
name=data.name,
description=data.description,
)
session.add(profile)
await session.commit()
await session.refresh(profile)
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@router.get(
"/defaults",
summary="Get default profiles",
description="Get the current user's default profile assignments per tool type.",
)
async def get_default_profiles(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get default profiles for the current user."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
if user_config is None:
return {"default_profiles": {}}
return {"default_profiles": user_config.default_profiles}
@router.put(
"/defaults",
summary="Set default profiles",
description="Set the current user's default profile assignments per tool type.",
)
async def set_default_profiles(
data: DefaultProfilesUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Set default profiles for the current user."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
if user_config is None:
user_config = UserConfig(user_id=user_id, config={})
session.add(user_config)
# Validate all profile IDs belong to the user
for tool_type_id, profile_id_str in data.default_profiles.items():
profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"profile {profile_id_str} not found",
)
if profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"profile {profile_id_str} does not belong to user",
)
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
user_config.config = {**user_config.config, "default_profiles": data.default_profiles}
await session.commit()
await session.refresh(user_config)
return {"default_profiles": user_config.default_profiles}
@router.get(
"/defaults/{tool_type_id}",
summary="Get default profile for tool type",
description="Get the default profile ID for a specific tool type.",
)
async def get_default_profile_for_tool_type(
tool_type_id: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get default profile for a specific tool type."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
if user_config is None:
return {"tool_type_id": tool_type_id, "profile_id": None}
profile_id = user_config.default_profiles.get(tool_type_id)
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
@router.get(
"/{profile_id}",
summary="Get config profile",
description="Get a config profile with its includes and mounts.",
)
async def get_config_profile(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get a config profile with includes and mounts."""
profile = await session.get(
ConfigProfile,
profile_id,
options=[
selectinload(ConfigProfile.includes),
selectinload(ConfigProfile.mounts),
],
)
if profile is None or profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="config profile not found",
)
# Fetch included profile names
includes_data = []
for inc in profile.includes:
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
includes_data.append({
"id": str(inc.id),
"profile_id": str(inc.profile_id),
"included_profile_id": str(inc.included_profile_id),
"included_profile_name": included_profile.name if included_profile else None,
"order_index": inc.order_index,
"created_at": inc.created_at.isoformat() if inc.created_at else None,
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
})
mounts_data = [
{
"id": str(m.id),
"profile_id": str(m.profile_id),
"target_path": m.target_path,
"mode": m.mode,
"files": m.files,
"order_index": m.order_index,
"created_at": m.created_at.isoformat() if m.created_at else None,
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
}
for m in profile.mounts
]
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"includes": includes_data,
"mounts": mounts_data,
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@router.put(
"/{profile_id}",
summary="Update config profile",
description="Update an existing config profile.",
)
async def update_config_profile(
profile_id: uuid.UUID,
data: ConfigProfileUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a config profile."""
profile = await _get_owned_profile(profile_id, user_id, session)
if data.name is not None:
# Check for duplicate name
existing = await session.scalar(
select(ConfigProfile).where(
ConfigProfile.user_id == user_id,
ConfigProfile.name == data.name,
ConfigProfile.id != profile_id,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"config profile with name '{data.name}' already exists",
)
profile.name = data.name
if data.description is not None:
profile.description = data.description
await session.commit()
await session.refresh(profile)
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@router.delete(
"/{profile_id}",
summary="Delete config profile",
description="Delete a config profile and all its includes and mounts.",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_config_profile(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete a config profile."""
profile = await _get_owned_profile(profile_id, user_id, session)
await session.delete(profile)
await session.commit()
# ---------------------------------------------------------------------------
# Include management
# ---------------------------------------------------------------------------
@router.get(
"/{profile_id}/includes",
summary="List profile includes",
description="Get all includes for a config profile.",
)
async def list_profile_includes(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List includes for a config profile."""
await _get_owned_profile(profile_id, user_id, session)
result = await session.execute(
select(ConfigInclude)
.where(ConfigInclude.profile_id == profile_id)
.order_by(ConfigInclude.order_index)
)
includes = result.scalars().all()
includes_data = []
for inc in includes:
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
includes_data.append({
"id": str(inc.id),
"profile_id": str(inc.profile_id),
"included_profile_id": str(inc.included_profile_id),
"included_profile_name": included_profile.name if included_profile else None,
"order_index": inc.order_index,
"created_at": inc.created_at.isoformat() if inc.created_at else None,
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
})
return {"includes": includes_data}
@router.post(
"/{profile_id}/includes",
summary="Add profile include",
description="Add an include to a config profile.",
status_code=status.HTTP_201_CREATED,
)
async def add_profile_include(
profile_id: uuid.UUID,
data: ConfigIncludeCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Add an include to a config profile."""
profile = await _get_owned_profile(profile_id, user_id, session)
included_profile_id = uuid.UUID(data.included_profile_id)
# Cannot include self
if included_profile_id == profile_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="a profile cannot include itself",
)
# Verify the included profile exists and belongs to the user
included_profile = await session.get(ConfigProfile, included_profile_id)
if included_profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="included profile not found",
)
if included_profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="included profile does not belong to user",
)
# Check for duplicate include
existing = await session.scalar(
select(ConfigInclude).where(
ConfigInclude.profile_id == profile_id,
ConfigInclude.included_profile_id == included_profile_id,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="this include already exists",
)
# Validate no cycles
await _validate_includes_no_cycle(session, profile_id, included_profile_id)
include = ConfigInclude(
profile_id=profile_id,
included_profile_id=included_profile_id,
order_index=data.order_index,
)
session.add(include)
await session.commit()
await session.refresh(include)
return {
"id": str(include.id),
"profile_id": str(include.profile_id),
"included_profile_id": str(include.included_profile_id),
"included_profile_name": included_profile.name,
"order_index": include.order_index,
"created_at": include.created_at.isoformat() if include.created_at else None,
"updated_at": include.updated_at.isoformat() if include.updated_at else None,
}
@router.put(
"/{profile_id}/includes/{include_id}",
summary="Update profile include",
description="Update the order index of a profile include.",
)
async def update_profile_include(
profile_id: uuid.UUID,
include_id: uuid.UUID,
data: ConfigIncludeUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a profile include."""
await _get_owned_profile(profile_id, user_id, session)
include = await session.get(ConfigInclude, include_id)
if include is None or include.profile_id != profile_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="include not found",
)
include.order_index = data.order_index
await session.commit()
await session.refresh(include)
included_profile = await session.get(ConfigProfile, include.included_profile_id)
return {
"id": str(include.id),
"profile_id": str(include.profile_id),
"included_profile_id": str(include.included_profile_id),
"included_profile_name": included_profile.name if included_profile else None,
"order_index": include.order_index,
"created_at": include.created_at.isoformat() if include.created_at else None,
"updated_at": include.updated_at.isoformat() if include.updated_at else None,
}
@router.delete(
"/{profile_id}/includes/{include_id}",
summary="Remove profile include",
description="Remove an include from a config profile.",
status_code=status.HTTP_204_NO_CONTENT,
)
async def remove_profile_include(
profile_id: uuid.UUID,
include_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Remove an include from a config profile."""
await _get_owned_profile(profile_id, user_id, session)
include = await session.get(ConfigInclude, include_id)
if include is None or include.profile_id != profile_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="include not found",
)
await session.delete(include)
await session.commit()
# ---------------------------------------------------------------------------
# Mount management
# ---------------------------------------------------------------------------
@router.get(
"/{profile_id}/mounts",
summary="List profile mounts",
description="Get all mounts for a config profile.",
)
async def list_profile_mounts(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List mounts for a config profile."""
await _get_owned_profile(profile_id, user_id, session)
result = await session.execute(
select(ConfigMount)
.where(ConfigMount.profile_id == profile_id)
.order_by(ConfigMount.order_index)
)
mounts = result.scalars().all()
return {
"mounts": [
{
"id": str(m.id),
"profile_id": str(m.profile_id),
"target_path": m.target_path,
"files": m.files,
"mode": m.mode,
"order_index": m.order_index,
"created_at": m.created_at.isoformat() if m.created_at else None,
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
}
for m in mounts
]
}
@router.post(
"/{profile_id}/mounts",
summary="Add profile mount",
description="Add a mount to a config profile.",
status_code=status.HTTP_201_CREATED,
)
async def add_profile_mount(
profile_id: uuid.UUID,
data: ConfigMountCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Add a mount to a config profile."""
profile = await _get_owned_profile(profile_id, user_id, session)
# Check for duplicate target_path
existing = await session.scalar(
select(ConfigMount).where(
ConfigMount.profile_id == profile_id,
ConfigMount.target_path == data.target_path,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"mount with path '{data.target_path}' already exists",
)
mount = ConfigMount(
profile_id=profile_id,
target_path=data.target_path,
mode=data.mode,
files=data.files,
order_index=data.order_index,
)
session.add(mount)
await session.commit()
await session.refresh(mount)
return {
"id": str(mount.id),
"profile_id": str(mount.profile_id),
"target_path": mount.target_path,
"files": mount.files,
"mode": mount.mode,
"order_index": mount.order_index,
"created_at": mount.created_at.isoformat() if mount.created_at else None,
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
}
@router.put(
"/{profile_id}/mounts/{mount_id}",
summary="Update profile mount",
description="Update a mount in a config profile.",
)
async def update_profile_mount(
profile_id: uuid.UUID,
mount_id: uuid.UUID,
data: ConfigMountUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a profile mount."""
await _get_owned_profile(profile_id, user_id, session)
mount = await session.get(ConfigMount, mount_id)
if mount is None or mount.profile_id != profile_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="mount not found",
)
if data.target_path is not None:
# Check for duplicate target_path
existing = await session.scalar(
select(ConfigMount).where(
ConfigMount.profile_id == profile_id,
ConfigMount.target_path == data.target_path,
ConfigMount.id != mount_id,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"mount with path '{data.target_path}' already exists",
)
mount.target_path = data.target_path
if data.files is not None:
mount.files = data.files
if data.order_index is not None:
mount.order_index = data.order_index
await session.commit()
await session.refresh(mount)
return {
"id": str(mount.id),
"profile_id": str(mount.profile_id),
"target_path": mount.target_path,
"files": mount.files,
"mode": mount.mode,
"order_index": mount.order_index,
"created_at": mount.created_at.isoformat() if mount.created_at else None,
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
}
@router.delete(
"/{profile_id}/mounts/{mount_id}",
summary="Remove profile mount",
description="Remove a mount from a config profile.",
status_code=status.HTTP_204_NO_CONTENT,
)
async def remove_profile_mount(
profile_id: uuid.UUID,
mount_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Remove a mount from a config profile."""
await _get_owned_profile(profile_id, user_id, session)
mount = await session.get(ConfigMount, mount_id)
if mount is None or mount.profile_id != profile_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="mount not found",
)
await session.delete(mount)
await session.commit()
+171 -22
View File
@@ -4,7 +4,7 @@ import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -24,6 +24,91 @@ 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 ToolConfigUpdate(BaseModel):
key: str | None = Field(default=None, description="Config key name")
value: str | None = Field(default=None, description="Config value")
config_type: str | None = Field(default=None, 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 +119,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.")
@@ -42,7 +132,7 @@ async def list_configs(
project_id: str | None = None,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
) -> list:
"""List tool configs for the current user."""
query = select(ToolConfig).where(ToolConfig.user_id == user_id)
@@ -57,23 +147,26 @@ async def list_configs(
result = await session.execute(query)
configs = result.scalars().all()
return {
"configs": [
{
"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,
}
for c in configs
]
}
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.")
@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),
@@ -111,6 +204,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,13 +222,18 @@ 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,
}
@router.put("/{config_id}", summary="Update tool config", description="Update an existing tool config.")
async def update_config(
config_id: uuid.UUID,
data: ToolConfigCreate,
data: ToolConfigUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
@@ -139,10 +242,24 @@ async def update_config(
if config is None or config.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
config.key = data.key
config.value = data.value
config.config_type = data.config_type
config.file_path = data.file_path
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)
@@ -155,6 +272,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,
"suggested_configs": defaults,
}
+230 -13
View File
@@ -18,10 +18,12 @@ from src.auth.dependencies import get_current_user_id
from src.auth.dependencies import get_db_session
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.config_profile import ConfigProfile
from src.models.tool_config import ToolConfig
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.models.user import User
from src.models.config_folder import ConfigFolder
from src.services.docker import (
check_tunnel_health,
connect_container_to_network,
@@ -37,7 +39,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"])
@@ -49,6 +54,59 @@ class CreateInstanceRequest(BaseModel):
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
display_name: str | None = Field(default=None, description="Optional display name for the instance")
config_profile_id: str | None = Field(default=None, description="Optional config profile ID to apply to 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:
@@ -133,6 +191,29 @@ async def create_instance(
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
)
# Validate config_profile_id if provided
selected_profile_id: uuid.UUID | None = None
if data.config_profile_id:
try:
selected_profile_id = uuid.UUID(data.config_profile_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid config_profile_id format",
)
config_profile = await session.get(ConfigProfile, selected_profile_id)
if config_profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="config profile not found",
)
if config_profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="config profile does not belong to user",
)
try:
# Generate unique name
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
@@ -145,18 +226,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(
@@ -169,6 +287,7 @@ async def create_instance(
status="pending",
compose_path=compose_path,
port=tool_port,
selected_profile_id=selected_profile_id,
)
session.add(instance)
await session.commit()
@@ -180,6 +299,7 @@ async def create_instance(
"display_name": instance.display_name,
"tool_type_id": str(instance.tool_type_id),
"status": instance.status,
"config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None,
"created_at": instance.created_at.isoformat(),
}
except Exception as exc:
@@ -242,6 +362,7 @@ async def list_instances(
"status": i.status,
"url": i.url,
"port": i.port,
"config_profile_id": str(i.selected_profile_id) if i.selected_profile_id else None,
"created_at": i.created_at.isoformat(),
})
@@ -302,6 +423,7 @@ async def get_instance(
"compose_path": instance.compose_path,
"url": instance.url,
"port": instance.port,
"config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None,
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
"created_at": instance.created_at.isoformat(),
@@ -353,7 +475,28 @@ 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 = []
if instance.selected_profile_id:
# Validate the selected config profile
selected_profile = await session.get(ConfigProfile, instance.selected_profile_id)
if selected_profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="config profile not found",
)
if selected_profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="config profile does not belong to user",
)
logger.info("Using selected config profile %s for instance %s", instance.selected_profile_id, instance.id)
# Fetch all matching configs for this tool type
config_query = select(ToolConfig).where(
ToolConfig.user_id == user_id,
ToolConfig.tool_type_id == instance.tool_type_id,
@@ -370,6 +513,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 +549,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 +597,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
+265 -56
View File
@@ -3,7 +3,7 @@ from datetime import datetime
import yaml
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, field_validator
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -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:
@@ -122,22 +160,48 @@ class ToolTypeCreate(BaseModel):
return v
@model_validator(mode="after")
def validate_templates(self) -> "ToolTypeCreate":
if self.definition_type == "dockerfile" and self.dockerfile_template is None:
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
if self.definition_type == "compose" and self.compose_template is None:
raise ValueError("compose_template is required when definition_type is 'compose'")
return self
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 +218,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 +245,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,8 +292,14 @@ 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,
category=data.category,
interfaces=data.interfaces,
is_builtin=False,
created_by_id=user.id,
)
@@ -315,54 +405,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 +467,120 @@ async def update_tool_type(
return tool_type
class ToolTypeValidateRequest(BaseModel):
definition_type: str
compose_template: str | None = None
dockerfile_template: str | None = None
@router.post(
"/validate",
summary="Validate tool type template",
description="Validate a compose template or dockerfile syntax before creating a tool type.",
)
async def validate_tool_type_template(
data: ToolTypeValidateRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Validate a tool type template syntax.
Args:
data: Validation request with definition type and template.
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)
errors = []
if data.definition_type == "compose":
if not data.compose_template:
errors.append("Compose template is required")
else:
try:
parsed = yaml.safe_load(data.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 data.definition_type == "dockerfile":
if not data.dockerfile_template:
errors.append("Dockerfile template is required")
elif not data.dockerfile_template.strip().startswith("FROM"):
errors.append("Dockerfile must start with a FROM instruction")
else:
errors.append("definition_type must be 'compose' or 'dockerfile'")
return {
"valid": len(errors) == 0,
"errors": errors,
}
@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,
+3 -3
View File
@@ -2,7 +2,7 @@ import hmac
import hashlib
import json
import base64
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Any
from src.config import Settings
@@ -23,7 +23,7 @@ def create_session_cookie(*, settings: Settings, user_id: str) -> str:
"""Create a signed session cookie value."""
payload = {
"user_id": user_id,
"exp": int((datetime.now(UTC) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
"exp": int((datetime.now(timezone.utc) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
}
header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode())
@@ -65,7 +65,7 @@ def decode_session_cookie(*, settings: Settings, cookie_value: str) -> dict[str,
payload = json.loads(payload_bytes)
# Check expiry
if payload.get("exp", 0) < int(datetime.now(UTC).timestamp()):
if payload.get("exp", 0) < int(datetime.now(timezone.utc).timestamp()):
raise ValueError("session expired")
return payload
+35 -1
View File
@@ -1,3 +1,4 @@
import json
import logging
import os
@@ -16,6 +17,8 @@ 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
@@ -58,6 +61,32 @@ app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(ExceptionLoggingMiddleware)
def _sanitize_validation_errors(errors):
"""Convert validation errors to JSON-safe format."""
sanitized = []
for error in errors:
safe_error = {
"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,
}
# Convert ctx to safe format
ctx = error.get("ctx")
if ctx:
safe_ctx = {}
for key, value in ctx.items():
if isinstance(value, Exception):
safe_ctx[key] = str(value)
elif isinstance(value, (str, int, float, bool, type(None))):
safe_ctx[key] = value
else:
safe_ctx[key] = str(value)
safe_error["ctx"] = safe_ctx
sanitized.append(safe_error)
return sanitized
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Log validation errors and return detailed response."""
@@ -68,9 +97,10 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
request.url.path,
errors,
)
safe_errors = _sanitize_validation_errors(errors)
return JSONResponse(
status_code=422,
content={"detail": errors},
content={"detail": safe_errors},
)
@@ -199,6 +229,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 +243,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 +277,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)
+18 -1
View File
@@ -1,4 +1,8 @@
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
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
@@ -7,4 +11,17 @@ 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",
"ConfigInclude",
"ConfigMount",
"ConfigProfile",
"GitRepository",
"Project",
"SSHKey",
"ToolInstance",
"ToolType",
"User",
"UserConfig",
]
+33
View File
@@ -0,0 +1,33 @@
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()
+36
View File
@@ -0,0 +1,36 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, UniqueConstraint
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.config_profile import ConfigProfile
class ConfigInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_includes"
__table_args__ = (
UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
)
profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
included_profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[profile_id],
back_populates="includes",
)
included_profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[included_profile_id],
)
+31
View File
@@ -0,0 +1,31 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, JSON, String
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.config_profile import ConfigProfile
class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_mounts"
profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
target_path: Mapped[str] = mapped_column(String(1024), nullable=False)
mode: Mapped[str] = mapped_column(String(10), nullable=False, default="rw")
files: Mapped[dict[str, str] | None] = mapped_column(
JSON, default=dict, nullable=True
)
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[profile_id],
back_populates="mounts",
)
+59
View File
@@ -0,0 +1,59 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, JSON, String, Text, UniqueConstraint
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.config_include import ConfigInclude
from src.models.config_mount import ConfigMount
from src.models.project import Project
from src.models.tool_type import ToolType
from src.models.user import User
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_profiles"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
project_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
)
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
environment_variables: Mapped[dict[str, str] | None] = mapped_column(
JSON, default=dict, nullable=True
)
start_command: Mapped[str | None] = mapped_column(Text, nullable=True)
working_directory: Mapped[str | None] = mapped_column(Text, nullable=True)
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
is_default: Mapped[bool] = mapped_column(default=False, nullable=False)
user: Mapped["User"] = relationship()
project: Mapped["Project | None"] = relationship()
tool_type: Mapped["ToolType | None"] = relationship()
includes: Mapped[list["ConfigInclude"]] = relationship(
"ConfigInclude",
primaryjoin="ConfigProfile.id == ConfigInclude.profile_id",
back_populates="profile",
cascade="all, delete-orphan",
order_by="ConfigInclude.order_index",
)
mounts: Mapped[list["ConfigMount"]] = relationship(
"ConfigMount",
primaryjoin="ConfigProfile.id == ConfigMount.profile_id",
back_populates="profile",
cascade="all, delete-orphan",
order_by="ConfigMount.order_index",
)
+10 -1
View File
@@ -1,7 +1,7 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy import ForeignKey, JSON, String, Text
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -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()
+5
View File
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.config_profile import ConfigProfile
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.tool_type import ToolType
@@ -62,8 +63,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
last_stopped_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
selected_profile_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
)
tool_type: Mapped["ToolType"] = relationship()
repository: Mapped["GitRepository"] = relationship()
project: Mapped["Project"] = relationship()
owner: Mapped["User"] = relationship()
selected_profile: Mapped["ConfigProfile | None"] = relationship()
+9 -1
View File
@@ -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(
+20
View File
@@ -18,3 +18,23 @@ class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
user: Mapped["User"] = relationship(back_populates="user_config")
@property
def default_profile_id(self) -> uuid.UUID | None:
profile_id = self.config.get("default_profile_id")
return uuid.UUID(profile_id) if profile_id else None
@default_profile_id.setter
def default_profile_id(self, value: uuid.UUID | None) -> None:
if value is not None:
self.config["default_profile_id"] = str(value)
elif "default_profile_id" in self.config:
del self.config["default_profile_id"]
@property
def default_profiles(self) -> dict[str, str]:
return self.config.get("default_profiles", {})
@default_profiles.setter
def default_profiles(self, value: dict[str, str]) -> None:
self.config["default_profiles"] = value
+53
View File
@@ -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]:
+69
View File
@@ -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)
+251
View File
@@ -0,0 +1,251 @@
"""Profile resolver service for recursive ordered include resolution.
Provides deterministic merge rules, save-independent cycle protection,
and resolved output structures for env vars, runtime hints, mounts,
file trees, and override metadata.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from src.models.config_include import ConfigInclude
from src.models.config_mount import ConfigMount
from src.models.config_profile import ConfigProfile
@dataclass
class ResolvedMount:
"""A resolved mount with merged file tree and final mode."""
target_path: str
mode: str # "ro" or "rw"
files: dict[str, str] = field(default_factory=dict)
"""Relative file paths to UTF-8 text content."""
overridden_files: dict[str, list[str]] = field(default_factory=dict)
"""Map of relative file path to list of profile names that contributed
(latest is the winner)."""
mode_overridden_by: str | None = None
"""Name of the profile that set the final mode, if different from first."""
@dataclass
class ResolvedRuntimeHints:
"""Resolved runtime hints from profile layers."""
start_command: str | None = None
working_directory: str | None = None
port: int | None = None
overridden_hints: dict[str, str] = field(default_factory=dict)
"""Map of hint key to profile name that provided the winning value."""
@dataclass
class ResolvedProfileOutput:
"""Complete resolved output for a config profile."""
profile_id: uuid.UUID
profile_name: str
environment_variables: dict[str, str] = field(default_factory=dict)
"""Final merged env vars (later layers win)."""
env_var_sources: dict[str, list[str]] = field(default_factory=dict)
"""Map of env var key to ordered list of contributing profile names
(latest is the winner)."""
runtime_hints: ResolvedRuntimeHints = field(
default_factory=lambda: ResolvedRuntimeHints()
)
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
"""Map of target_path to ResolvedMount."""
resolution_order: list[str] = field(default_factory=list)
"""Ordered list of profile names as they were resolved."""
cycle_detected: bool = False
cycle_path: list[str] | None = None
class ProfileResolutionError(Exception):
"""Raised when profile resolution fails."""
pass
class ProfileCycleError(ProfileResolutionError):
"""Raised when a cycle is detected during profile resolution."""
def __init__(self, cycle_path: list[str]) -> None:
self.cycle_path = cycle_path
path_str = " -> ".join(cycle_path)
super().__init__(f"Profile include cycle detected: {path_str}")
def _merge_env_vars(
current: dict[str, str],
sources: dict[str, list[str]],
profile: ConfigProfile,
) -> None:
"""Merge a profile's env vars into the current dict, tracking sources."""
if not profile.environment_variables:
return
for key, value in profile.environment_variables.items():
current[key] = value
if key not in sources:
sources[key] = []
sources[key].append(profile.name)
def _merge_runtime_hints(
hints: ResolvedRuntimeHints,
profile: ConfigProfile,
) -> None:
"""Merge a profile's runtime hints, tracking overrides."""
if profile.start_command is not None:
hints.start_command = profile.start_command
hints.overridden_hints["start_command"] = profile.name
if profile.working_directory is not None:
hints.working_directory = profile.working_directory
hints.overridden_hints["working_directory"] = profile.name
if profile.port is not None:
hints.port = profile.port
hints.overridden_hints["port"] = profile.name
def _merge_mounts(
mounts: dict[str, ResolvedMount],
profile_mounts: list[ConfigMount],
profile: ConfigProfile,
) -> None:
"""Merge a profile's mounts into the current mounts dict."""
for mount in profile_mounts:
target = mount.target_path
if target not in mounts:
mounts[target] = ResolvedMount(
target_path=target,
mode=mount.mode,
files={},
overridden_files={},
)
resolved = mounts[target]
# Mode override: later wins
if resolved.mode != mount.mode:
resolved.mode = mount.mode
resolved.mode_overridden_by = profile.name
# File tree merge: later wins for same relative path
if mount.files:
for rel_path, content in mount.files.items():
if rel_path not in resolved.files:
resolved.overridden_files[rel_path] = []
else:
if rel_path not in resolved.overridden_files:
resolved.overridden_files[rel_path] = []
resolved.overridden_files[rel_path].append(profile.name)
resolved.files[rel_path] = content
def _resolve_profile_recursive(
profile: ConfigProfile,
visited: set[uuid.UUID],
path: list[str],
resolution_order: list[str],
env_vars: dict[str, str],
env_var_sources: dict[str, list[str]],
runtime_hints: ResolvedRuntimeHints,
mounts: dict[str, ResolvedMount],
) -> None:
"""Recursively resolve a profile and its includes.
Args:
profile: The profile to resolve
visited: Set of already-resolved profile IDs to avoid duplicates
path: Current recursion path for cycle detection
resolution_order: Ordered list of profile names being resolved
env_vars: Accumulated environment variables
env_var_sources: Tracking of which profiles contributed each env var
runtime_hints: Accumulated runtime hints
mounts: Accumulated mounts
Raises:
ProfileCycleError: If a cycle is detected
"""
if profile.name in path:
# Cycle detected
cycle_start = path.index(profile.name)
cycle_path = path[cycle_start:] + [profile.name]
raise ProfileCycleError(cycle_path)
if profile.id in visited:
# Already resolved in another branch (diamond graph)
return
visited.add(profile.id)
path.append(profile.name)
resolution_order.append(profile.name)
# Resolve includes first (in order)
includes: list[ConfigInclude] = list(profile.includes)
includes.sort(key=lambda inc: inc.order_index)
for include in includes:
included_profile = include.included_profile
if included_profile is not None:
_resolve_profile_recursive(
included_profile,
visited,
path,
resolution_order,
env_vars,
env_var_sources,
runtime_hints,
mounts,
)
# Apply this profile's values (later layers win)
_merge_env_vars(env_vars, env_var_sources, profile)
_merge_runtime_hints(runtime_hints, profile)
_merge_mounts(mounts, list(profile.mounts), profile)
path.pop()
def resolve_profile(profile: ConfigProfile) -> ResolvedProfileOutput:
"""Resolve a config profile with all its includes.
Processes included profiles in configured order, then applies the
selected profile itself. Later layers override earlier layers.
Args:
profile: The root profile to resolve
Returns:
ResolvedProfileOutput with merged env vars, runtime hints, mounts,
and override metadata
Raises:
ProfileCycleError: If a cycle is detected in the include graph
"""
env_vars: dict[str, str] = {}
env_var_sources: dict[str, list[str]] = {}
runtime_hints = ResolvedRuntimeHints()
mounts: dict[str, ResolvedMount] = {}
resolution_order: list[str] = []
_resolve_profile_recursive(
profile,
set(),
[],
resolution_order,
env_vars,
env_var_sources,
runtime_hints,
mounts,
)
return ResolvedProfileOutput(
profile_id=profile.id,
profile_name=profile.name,
environment_variables=env_vars,
env_var_sources=env_var_sources,
runtime_hints=runtime_hints,
mounts=mounts,
resolution_order=resolution_order,
)
+66
View File
@@ -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)
+5 -6
View File
@@ -121,12 +121,11 @@ def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None:
Raises:
RuntimeError: If branch creation fails
"""
if base_branch == "HEAD":
try:
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD")
except RuntimeError:
_run_git_command(repo_path, "checkout", "--orphan", name)
return
try:
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}")
except RuntimeError:
_run_git_command(repo_path, "checkout", "--orphan", name)
return
_run_git_command(repo_path, "branch", name, base_branch)
+131 -70
View File
@@ -3,6 +3,7 @@
import asyncio
import os
from typing import AsyncGenerator, Generator
from unittest.mock import patch
import pytest
import pytest_asyncio
@@ -11,82 +12,142 @@ from sqlalchemy import create_engine, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker
# Set test environment BEFORE importing app modules
os.environ["APP_ENV"] = "testing"
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
from src.config import Settings, build_database_url
from src.models.base import Base
from src.main import app
# Unit test fixtures (SQLite in-memory)
@pytest.fixture(scope="session")
def sqlite_engine():
"""Create a SQLite in-memory engine for unit tests."""
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def sqlite_session(sqlite_engine) -> Generator:
"""Provide a SQLite session for unit tests."""
connection = sqlite_engine.connect()
transaction = connection.begin()
session = sessionmaker(bind=connection)()
yield session
session.close()
transaction.rollback()
connection.close()
# Integration test fixtures (PostgreSQL)
TEST_DATABASE_URL = build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
@pytest_asyncio.fixture(scope="session")
async def postgres_engine():
"""Create a PostgreSQL engine for integration tests."""
engine = create_async_engine(TEST_DATABASE_URL)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(postgres_engine) -> AsyncGenerator[AsyncSession, None]:
"""Provide a database session with transaction rollback."""
async with postgres_engine.connect() as connection:
transaction = await connection.begin_nested()
session_factory = async_sessionmaker(
connection, expire_on_commit=False, class_=AsyncSession
)
session = session_factory()
yield session
await session.close()
await transaction.rollback()
from src.auth.dependencies import get_db_session
@pytest.fixture
def test_client() -> Generator[TestClient, None, None]:
"""Provide a FastAPI test client."""
with TestClient(app) as client:
yield client
"""Provide a FastAPI test client with SQLite database."""
# Create a single engine for this test
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
connect_args={"check_same_thread": False},
)
# Create tables
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
asyncio.run(init_db())
async def override_get_db_session() -> AsyncGenerator[AsyncSession, None]:
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
yield session
# Override the dependency
app.dependency_overrides[get_db_session] = override_get_db_session
# Patch startup events to prevent PostgreSQL connection attempts
with patch("src.main.init_database") as mock_init, \
patch("src.main.seed_builtin_tool_types") as mock_seed:
mock_init.return_value = True
mock_seed.return_value = None
try:
with TestClient(app) as client:
yield client
finally:
# Clean up overrides
app.dependency_overrides.pop(get_db_session, None)
asyncio.run(engine.dispose())
@pytest.fixture(autouse=True)
def configure_test_env(monkeypatch):
"""Configure environment for testing."""
monkeypatch.setenv("DATABASE_URL", TEST_DATABASE_URL)
monkeypatch.setenv("APP_ENV", "testing")
@pytest.fixture
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
"""Provide an authenticated test client with a test user."""
import uuid
from src.auth.session import create_session_cookie
from src.models.user import User
user_id = str(uuid.uuid4())
settings = Settings()
# Create user in database using the same engine as test_client
# We need to access the engine from the test_client fixture
# Since we can't easily do that, we'll create the user via API call
# But we need the user to exist before any API calls
# So we need to create the user using the overridden dependency
async def create_test_user():
# Get the override function
override_fn = app.dependency_overrides.get(get_db_session)
if override_fn:
gen = override_fn()
session = await gen.asend(None)
try:
user = User(
id=uuid.UUID(user_id),
email="test@headquarter.local",
name="Test User",
authentik_id=f"authentik-{user_id}",
avatar_url=None,
)
session.add(user)
await session.commit()
finally:
await gen.aclose()
asyncio.run(create_test_user())
# Create session cookie
session_cookie = create_session_cookie(
settings=settings,
user_id=user_id,
)
# Set cookie on client
test_client.cookies.set("session", session_cookie)
yield test_client
@pytest.fixture
def admin_client(test_client) -> Generator[TestClient, None, None]:
"""Provide an authenticated test client with an admin user."""
import uuid
from src.auth.session import create_session_cookie
from src.models.user import User
user_id = str(uuid.uuid4())
settings = Settings()
async def create_admin_user():
override_fn = app.dependency_overrides.get(get_db_session)
if override_fn:
gen = override_fn()
session = await gen.asend(None)
try:
user = User(
id=uuid.UUID(user_id),
email="admin@headquarter.local",
name="Admin User",
authentik_id=f"authentik-admin-{user_id}",
avatar_url=None,
is_admin=True,
)
session.add(user)
await session.commit()
finally:
await gen.aclose()
asyncio.run(create_admin_user())
# Create session cookie
session_cookie = create_session_cookie(
settings=settings,
user_id=user_id,
)
# Set cookie on client
test_client.cookies.set("session", session_cookie)
yield test_client
@@ -0,0 +1,255 @@
import uuid
import pytest
from fastapi.testclient import TestClient
@pytest.mark.integration
class TestConfigFoldersAPI:
"""Integration tests for config folders API."""
def test_list_config_folders_requires_authentication(self, test_client: TestClient) -> None:
"""Test that listing config folders requires authentication."""
response = test_client.get("/config-folders")
assert response.status_code == 401
def test_list_config_folders_returns_user_folders(self, authenticated_client: TestClient) -> None:
"""Test that authenticated users can list their folders."""
response = authenticated_client.get("/config-folders")
assert response.status_code == 200
data = response.json()
assert isinstance(data, dict)
assert "folders" in data
assert isinstance(data["folders"], list)
def test_create_config_folder_successfully(self, authenticated_client: TestClient) -> None:
"""Test creating a config folder."""
response = authenticated_client.post(
"/config-folders",
json={
"name": "test-folder",
"description": "Test folder",
"mount_path": "/home/user",
"files": {"test.txt": "hello world"},
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "test-folder"
assert data["mount_path"] == "/home/user"
assert data["files"] == {"test.txt": "hello world"}
def test_create_config_folder_duplicate_name(self, authenticated_client: TestClient) -> None:
"""Test that duplicate folder names are rejected."""
# Create first folder
response = authenticated_client.post(
"/config-folders",
json={
"name": "duplicate-folder",
"mount_path": "/home/user",
"files": {},
},
)
assert response.status_code == 201
# Try to create second with same name
response = authenticated_client.post(
"/config-folders",
json={
"name": "duplicate-folder",
"mount_path": "/home/user",
"files": {},
},
)
assert response.status_code == 409
def test_create_config_folder_exceeds_size_limit(self, authenticated_client: TestClient) -> None:
"""Test that folders exceeding 10MB are rejected."""
large_content = "x" * (11 * 1024 * 1024) # 11MB
response = authenticated_client.post(
"/config-folders",
json={
"name": "large-folder",
"mount_path": "/home/user",
"files": {"large.txt": large_content},
},
)
assert response.status_code == 422
def test_create_config_folder_path_traversal_attack(self, authenticated_client: TestClient) -> None:
"""Test that path traversal in file paths is prevented."""
response = authenticated_client.post(
"/config-folders",
json={
"name": "bad-folder",
"mount_path": "/home/user",
"files": {"../../../etc/passwd": "malicious"},
},
)
assert response.status_code == 422
def test_get_config_folder_by_id(self, authenticated_client: TestClient) -> None:
"""Test getting a config folder by ID."""
# Create folder first
create_response = authenticated_client.post(
"/config-folders",
json={
"name": "get-test",
"mount_path": "/home/user",
"files": {},
},
)
folder_id = create_response.json()["id"]
# Get it back
response = authenticated_client.get(f"/config-folders/{folder_id}")
assert response.status_code == 200
data = response.json()
assert data["name"] == "get-test"
def test_get_config_folder_not_found(self, authenticated_client: TestClient) -> None:
"""Test getting a non-existent folder."""
response = authenticated_client.get(f"/config-folders/{uuid.uuid4()}")
assert response.status_code == 404
def test_update_config_folder_successfully(self, authenticated_client: TestClient) -> None:
"""Test updating a config folder."""
# Create folder first
create_response = authenticated_client.post(
"/config-folders",
json={
"name": "update-test",
"mount_path": "/home/user",
"files": {},
},
)
folder_id = create_response.json()["id"]
# Update it
response = authenticated_client.put(
f"/config-folders/{folder_id}",
json={
"name": "updated-name",
"mount_path": "/workspace",
"files": {"new.txt": "content"},
},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "updated-name"
assert data["mount_path"] == "/workspace"
def test_delete_config_folder_successfully(self, authenticated_client: TestClient) -> None:
"""Test deleting a config folder."""
# Create folder first
create_response = authenticated_client.post(
"/config-folders",
json={
"name": "delete-test",
"mount_path": "/home/user",
"files": {},
},
)
folder_id = create_response.json()["id"]
# Delete it
response = authenticated_client.delete(f"/config-folders/{folder_id}")
assert response.status_code == 204
# Verify it's gone
get_response = authenticated_client.get(f"/config-folders/{folder_id}")
assert get_response.status_code == 404
def test_add_project_override_successfully(self, authenticated_client: TestClient) -> None:
"""Test adding a project override."""
# Create folder first
create_response = authenticated_client.post(
"/config-folders",
json={
"name": "override-test",
"mount_path": "/home/user",
"files": {"global.txt": "global"},
},
)
folder_id = create_response.json()["id"]
project_id = str(uuid.uuid4())
# Add override
response = authenticated_client.post(
f"/config-folders/{folder_id}/overrides",
json={
"project_id": project_id,
"mount_path": "/workspace",
"files": {"project.txt": "project"},
},
)
assert response.status_code == 200
data = response.json()
assert project_id in data["project_overrides"]
def test_update_project_override_successfully(self, authenticated_client: TestClient) -> None:
"""Test updating a project override."""
# Create folder with override
create_response = authenticated_client.post(
"/config-folders",
json={
"name": "update-override-test",
"mount_path": "/home/user",
"files": {},
},
)
folder_id = create_response.json()["id"]
project_id = str(uuid.uuid4())
# Add override
authenticated_client.post(
f"/config-folders/{folder_id}/overrides",
json={
"project_id": project_id,
"mount_path": "/workspace",
"files": {"old.txt": "old"},
},
)
# Update override
response = authenticated_client.put(
f"/config-folders/{folder_id}/overrides/{project_id}",
json={
"mount_path": "/app",
"files": {"new.txt": "new"},
},
)
assert response.status_code == 200
data = response.json()
assert data["project_overrides"][project_id]["mount_path"] == "/app"
def test_delete_project_override_successfully(self, authenticated_client: TestClient) -> None:
"""Test deleting a project override."""
# Create folder with override
create_response = authenticated_client.post(
"/config-folders",
json={
"name": "delete-override-test",
"mount_path": "/home/user",
"files": {},
},
)
folder_id = create_response.json()["id"]
project_id = str(uuid.uuid4())
# Add override
authenticated_client.post(
f"/config-folders/{folder_id}/overrides",
json={
"project_id": project_id,
"mount_path": "/workspace",
"files": {},
},
)
# Delete override
response = authenticated_client.delete(
f"/config-folders/{folder_id}/overrides/{project_id}"
)
assert response.status_code == 200
data = response.json()
assert project_id not in data["project_overrides"]
@@ -0,0 +1,461 @@
"""Integration tests for config profiles API."""
import uuid
import pytest
from fastapi.testclient import TestClient
@pytest.mark.integration
class TestConfigProfilesAPI:
"""Integration tests for config profiles API."""
def test_list_config_profiles_requires_authentication(self, test_client: TestClient) -> None:
"""Test that listing config profiles requires authentication."""
response = test_client.get("/config-profiles")
assert response.status_code == 401
def test_list_config_profiles_returns_user_profiles(self, authenticated_client: TestClient) -> None:
"""Test that authenticated users can list their profiles."""
response = authenticated_client.get("/config-profiles")
assert response.status_code == 200
data = response.json()
assert isinstance(data, dict)
assert "profiles" in data
assert isinstance(data["profiles"], list)
def test_create_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test creating a config profile."""
response = authenticated_client.post(
"/config-profiles",
json={
"name": "test-profile",
"description": "Test profile",
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "test-profile"
assert data["description"] == "Test profile"
def test_create_config_profile_duplicate_name(self, authenticated_client: TestClient) -> None:
"""Test that duplicate profile names are rejected."""
authenticated_client.post(
"/config-profiles",
json={"name": "duplicate-profile"},
)
response = authenticated_client.post(
"/config-profiles",
json={"name": "duplicate-profile"},
)
assert response.status_code == 409
def test_create_config_profile_empty_name(self, authenticated_client: TestClient) -> None:
"""Test that empty profile names are rejected."""
response = authenticated_client.post(
"/config-profiles",
json={"name": " "},
)
assert response.status_code == 422
def test_get_config_profile_by_id(self, authenticated_client: TestClient) -> None:
"""Test getting a config profile by ID."""
create_response = authenticated_client.post(
"/config-profiles",
json={"name": "get-test"},
)
profile_id = create_response.json()["id"]
response = authenticated_client.get(f"/config-profiles/{profile_id}")
assert response.status_code == 200
data = response.json()
assert data["name"] == "get-test"
assert "includes" in data
assert "mounts" in data
def test_get_config_profile_not_found(self, authenticated_client: TestClient) -> None:
"""Test getting a non-existent profile."""
response = authenticated_client.get(f"/config-profiles/{uuid.uuid4()}")
assert response.status_code == 404
def test_update_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test updating a config profile."""
create_response = authenticated_client.post(
"/config-profiles",
json={"name": "update-test"},
)
profile_id = create_response.json()["id"]
response = authenticated_client.put(
f"/config-profiles/{profile_id}",
json={"name": "updated-name", "description": "updated desc"},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "updated-name"
assert data["description"] == "updated desc"
def test_delete_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test deleting a config profile."""
create_response = authenticated_client.post(
"/config-profiles",
json={"name": "delete-test"},
)
profile_id = create_response.json()["id"]
response = authenticated_client.delete(f"/config-profiles/{profile_id}")
assert response.status_code == 204
get_response = authenticated_client.get(f"/config-profiles/{profile_id}")
assert get_response.status_code == 404
def test_profile_access_check(self, authenticated_client: TestClient) -> None:
"""Test that users can only access their own profiles."""
# Create a profile
create_response = authenticated_client.post(
"/config-profiles",
json={"name": "access-test"},
)
profile_id = create_response.json()["id"]
# The profile should be accessible
response = authenticated_client.get(f"/config-profiles/{profile_id}")
assert response.status_code == 200
@pytest.mark.integration
class TestConfigProfileIncludes:
"""Integration tests for config profile includes."""
def test_add_include_successfully(self, authenticated_client: TestClient) -> None:
"""Test adding an include to a profile."""
# Create two profiles
profile1 = authenticated_client.post(
"/config-profiles",
json={"name": "profile-1"},
).json()
profile2 = authenticated_client.post(
"/config-profiles",
json={"name": "profile-2"},
).json()
# Add include
response = authenticated_client.post(
f"/config-profiles/{profile1['id']}/includes",
json={"included_profile_id": profile2["id"], "order_index": 0},
)
assert response.status_code == 201
data = response.json()
assert data["included_profile_id"] == profile2["id"]
assert data["included_profile_name"] == "profile-2"
def test_add_self_include_rejected(self, authenticated_client: TestClient) -> None:
"""Test that self-includes are rejected."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "self-include-test"},
).json()
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/includes",
json={"included_profile_id": profile["id"], "order_index": 0},
)
assert response.status_code == 400
def test_add_include_cycle_rejected(self, authenticated_client: TestClient) -> None:
"""Test that circular includes are rejected."""
profile1 = authenticated_client.post(
"/config-profiles",
json={"name": "cycle-1"},
).json()
profile2 = authenticated_client.post(
"/config-profiles",
json={"name": "cycle-2"},
).json()
# Add profile1 includes profile2
authenticated_client.post(
f"/config-profiles/{profile1['id']}/includes",
json={"included_profile_id": profile2["id"], "order_index": 0},
)
# Try to add profile2 includes profile1 (creates cycle)
response = authenticated_client.post(
f"/config-profiles/{profile2['id']}/includes",
json={"included_profile_id": profile1["id"], "order_index": 0},
)
assert response.status_code == 400
def test_add_deep_cycle_rejected(self, authenticated_client: TestClient) -> None:
"""Test that deep circular includes are rejected."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "deep-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "deep-2"}
).json()
p3 = authenticated_client.post(
"/config-profiles", json={"name": "deep-3"}
).json()
# p1 -> p2 -> p3
authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
)
authenticated_client.post(
f"/config-profiles/{p2['id']}/includes",
json={"included_profile_id": p3["id"], "order_index": 0},
)
# Try p3 -> p1 (creates cycle)
response = authenticated_client.post(
f"/config-profiles/{p3['id']}/includes",
json={"included_profile_id": p1["id"], "order_index": 0},
)
assert response.status_code == 400
def test_add_duplicate_include_rejected(self, authenticated_client: TestClient) -> None:
"""Test that duplicate includes are rejected."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "dup-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "dup-2"}
).json()
authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
)
response = authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 1},
)
assert response.status_code == 409
def test_list_includes(self, authenticated_client: TestClient) -> None:
"""Test listing includes for a profile."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "list-inc-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "list-inc-2"}
).json()
authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
)
response = authenticated_client.get(f"/config-profiles/{p1['id']}/includes")
assert response.status_code == 200
data = response.json()
assert len(data["includes"]) == 1
def test_update_include_order(self, authenticated_client: TestClient) -> None:
"""Test updating include order index."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "order-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "order-2"}
).json()
inc = authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
).json()
response = authenticated_client.put(
f"/config-profiles/{p1['id']}/includes/{inc['id']}",
json={"order_index": 5},
)
assert response.status_code == 200
assert response.json()["order_index"] == 5
def test_remove_include(self, authenticated_client: TestClient) -> None:
"""Test removing an include."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "rem-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "rem-2"}
).json()
inc = authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
).json()
response = authenticated_client.delete(
f"/config-profiles/{p1['id']}/includes/{inc['id']}"
)
assert response.status_code == 204
@pytest.mark.integration
class TestConfigProfileMounts:
"""Integration tests for config profile mounts."""
def test_add_mount_successfully(self, authenticated_client: TestClient) -> None:
"""Test adding a mount to a profile."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "mount-test"},
).json()
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/etc/config", "files": {"test.txt": "hello"}, "order_index": 0},
)
assert response.status_code == 201
data = response.json()
assert data["target_path"] == "/etc/config"
assert data["files"] == {"test.txt": "hello"}
def test_add_mount_relative_path_rejected(self, authenticated_client: TestClient) -> None:
"""Test that relative mount paths are rejected."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "rel-path-test"},
).json()
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "etc/config", "files": {"test.txt": "hello"}},
)
assert response.status_code == 422
def test_add_target_path_traversal_rejected(self, authenticated_client: TestClient) -> None:
"""Test that path traversal in mount paths is rejected."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "traversal-test"},
).json()
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/etc/../passwd", "files": {"test.txt": "hello"}},
)
assert response.status_code == 422
def test_add_duplicate_mount_rejected(self, authenticated_client: TestClient) -> None:
"""Test that duplicate mount paths are rejected."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "dup-mount-test"},
).json()
authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/etc/config", "files": {"test.txt": "hello"}},
)
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/etc/config", "files": {"test.txt": "world"}},
)
assert response.status_code == 409
def test_update_mount(self, authenticated_client: TestClient) -> None:
"""Test updating a mount."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "update-mount-test"},
).json()
mount = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/old/path", "files": {"test.txt": "old"}},
).json()
response = authenticated_client.put(
f"/config-profiles/{profile['id']}/mounts/{mount['id']}",
json={"target_path": "/new/path", "files": {"test.txt": "new"}, "order_index": 2},
)
assert response.status_code == 200
data = response.json()
assert data["target_path"] == "/new/path"
assert data["files"] == {"test.txt": "new"}
assert data["order_index"] == 2
def test_remove_mount(self, authenticated_client: TestClient) -> None:
"""Test removing a mount."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "rem-mount-test"},
).json()
mount = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/tmp/test", "files": {"test.txt": "x"}},
).json()
response = authenticated_client.delete(
f"/config-profiles/{profile['id']}/mounts/{mount['id']}"
)
assert response.status_code == 204
@pytest.mark.integration
class TestConfigProfileDefaults:
"""Integration tests for default profile APIs."""
def test_get_default_profiles_empty(self, authenticated_client: TestClient) -> None:
"""Test getting default profiles when none are set."""
response = authenticated_client.get("/config-profiles/defaults")
assert response.status_code == 200
data = response.json()
assert data["default_profiles"] == {}
def test_set_default_profiles(self, authenticated_client: TestClient) -> None:
"""Test setting default profiles."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "default-test"},
).json()
response = authenticated_client.put(
"/config-profiles/defaults",
json={"default_profiles": {"code-server": profile["id"]}},
)
assert response.status_code == 200
data = response.json()
assert data["default_profiles"]["code-server"] == profile["id"]
def test_set_default_profiles_invalid_profile(self, authenticated_client: TestClient) -> None:
"""Test setting default profiles with invalid profile ID."""
response = authenticated_client.put(
"/config-profiles/defaults",
json={"default_profiles": {"code-server": str(uuid.uuid4())}},
)
assert response.status_code == 404
def test_get_default_profile_for_tool_type(self, authenticated_client: TestClient) -> None:
"""Test getting default profile for a specific tool type."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "tool-default-test"},
).json()
authenticated_client.put(
"/config-profiles/defaults",
json={"default_profiles": {"jupyter-notebook": profile["id"]}},
)
response = authenticated_client.get("/config-profiles/defaults/jupyter-notebook")
assert response.status_code == 200
data = response.json()
assert data["tool_type_id"] == "jupyter-notebook"
assert data["profile_id"] == profile["id"]
def test_get_default_profile_for_tool_type_not_set(self, authenticated_client: TestClient) -> None:
"""Test getting default profile when not set."""
response = authenticated_client.get("/config-profiles/defaults/opencode")
assert response.status_code == 200
data = response.json()
assert data["tool_type_id"] == "opencode"
assert data["profile_id"] is None
@@ -5,7 +5,6 @@ from src.models import Base
from src.models.base import TimestampMixin, UUIDPrimaryKeyMixin
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.refresh_token import RefreshToken
from src.models.ssh_key import SSHKey
from src.models.user import User
from src.models.user_config import UserConfig
@@ -1,12 +1,12 @@
import uuid
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
import asyncio
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from src.auth.jwt_service import mint_access_token
from src.auth.session import create_session_cookie
from src.config import Settings, build_database_url
from src.models import Base
from src.models.project import Project
@@ -53,12 +53,12 @@ def _load_app():
def _mint_token(user_id: str) -> str:
settings = Settings()
return mint_access_token(
return create_session_cookie(
settings=settings,
subject=user_id,
email="test@headquarter.local",
name="Test User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
)
@@ -0,0 +1,256 @@
import uuid
import pytest
from fastapi.testclient import TestClient
@pytest.mark.integration
class TestToolConfigsAPIExtended:
"""Integration tests for tool configs API with new fields."""
def test_create_tool_config_with_new_fields(self, authenticated_client: TestClient) -> None:
"""Test creating a tool config with all new fields."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "config-test-tool",
"display_name": "Config Test Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = tool_response.json()["id"]
# Create config with new fields
response = authenticated_client.post(
"/tool-configs",
json={
"tool_type_id": tool_id,
"key": "ADVANCED_CONFIG",
"value": "test-value",
"config_type": "env",
"port_override": 9090,
"start_command": "python app.py",
"working_directory": "/app",
"environment_variables": {"DEBUG": "true", "LOG_LEVEL": "debug"},
"volumes": [
{"source": "data", "target": "/data", "type": "bind"}
],
},
)
assert response.status_code == 201
data = response.json()
assert data["key"] == "ADVANCED_CONFIG"
assert data["port_override"] == 9090
assert data["start_command"] == "python app.py"
assert data["working_directory"] == "/app"
assert data["environment_variables"] == {"DEBUG": "true", "LOG_LEVEL": "debug"}
assert data["volumes"] == [{"source": "data", "target": "/data", "type": "bind"}]
def test_create_tool_config_invalid_port(self, authenticated_client: TestClient) -> None:
"""Test that invalid port numbers are rejected."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "port-test-tool",
"display_name": "Port Test Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = tool_response.json()["id"]
# Try to create config with invalid port
response = authenticated_client.post(
"/tool-configs",
json={
"tool_type_id": tool_id,
"key": "BAD_PORT",
"value": "test",
"config_type": "env",
"port_override": 99999,
},
)
assert response.status_code == 422
def test_create_tool_config_invalid_volume_structure(self, authenticated_client: TestClient) -> None:
"""Test that invalid volume structures are rejected."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "volume-test-tool",
"display_name": "Volume Test Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = tool_response.json()["id"]
# Try to create config with invalid volume
response = authenticated_client.post(
"/tool-configs",
json={
"tool_type_id": tool_id,
"key": "BAD_VOLUME",
"value": "test",
"config_type": "env",
"volumes": [{"invalid": "structure"}],
},
)
assert response.status_code == 422
def test_update_tool_config_with_new_fields(self, authenticated_client: TestClient) -> None:
"""Test updating a tool config with new fields."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "update-config-tool",
"display_name": "Update Config Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = tool_response.json()["id"]
# Create config
create_response = authenticated_client.post(
"/tool-configs",
json={
"tool_type_id": tool_id,
"key": "UPDATE_TEST",
"value": "original",
"config_type": "env",
},
)
config_id = create_response.json()["id"]
# Update with new fields
response = authenticated_client.put(
f"/tool-configs/{config_id}",
json={
"value": "updated",
"port_override": 3000,
"start_command": "npm start",
"working_directory": "/workspace",
"environment_variables": {"NODE_ENV": "production"},
"volumes": [{"source": "src", "target": "/app/src", "type": "bind"}],
},
)
assert response.status_code == 200
data = response.json()
assert data["value"] == "updated"
assert data["port_override"] == 3000
assert data["start_command"] == "npm start"
assert data["working_directory"] == "/workspace"
assert data["environment_variables"] == {"NODE_ENV": "production"}
def test_list_tool_configs_returns_new_fields(self, authenticated_client: TestClient) -> None:
"""Test that listing configs returns new fields."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "list-config-tool",
"display_name": "List Config Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = tool_response.json()["id"]
# Create config with new fields
authenticated_client.post(
"/tool-configs",
json={
"tool_type_id": tool_id,
"key": "LIST_TEST",
"value": "test",
"config_type": "env",
"port_override": 5000,
"environment_variables": {"TEST": "true"},
},
)
# List configs
response = authenticated_client.get("/tool-configs")
assert response.status_code == 200
data = response.json()
assert len(data) > 0
config = data[0]
assert "port_override" in config
assert "start_command" in config
assert "working_directory" in config
assert "environment_variables" in config
assert "volumes" in config
def test_get_tool_config_defaults(self, authenticated_client: TestClient) -> None:
"""Test getting tool config defaults."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "defaults-tool",
"display_name": "Defaults Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n volumes:\n - \"{{REPO_PATH}}:/workspace\"\n",
"required_variables": ["REPO_PATH"],
},
)
tool_id = tool_response.json()["id"]
# Get defaults
response = authenticated_client.get(f"/tool-configs/defaults/{tool_id}")
assert response.status_code == 200
data = response.json()
assert data["tool_type_id"] == tool_id
assert "suggested_configs" in data
def test_tool_config_backward_compatibility(self, authenticated_client: TestClient) -> None:
"""Test that old configs without new fields still work."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "backward-compat-tool",
"display_name": "Backward Compat Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = tool_response.json()["id"]
# Create config without new fields (simulating old client)
response = authenticated_client.post(
"/tool-configs",
json={
"tool_type_id": tool_id,
"key": "OLD_STYLE",
"value": "value",
"config_type": "env",
},
)
assert response.status_code == 201
data = response.json()
assert data["key"] == "OLD_STYLE"
# New fields should have default values
assert data["port_override"] is None
assert data["start_command"] is None
assert data["working_directory"] is None
assert data["environment_variables"] is None
assert data["volumes"] is None
@@ -1,5 +1,5 @@
import uuid
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
import asyncio
import pytest
@@ -7,7 +7,7 @@ from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from src.auth.jwt_service import mint_access_token
from src.auth.session import create_session_cookie
from src.config import Settings, build_database_url
from src.models import Base
from src.models.tool_type import ToolType
@@ -54,12 +54,12 @@ def _load_app():
def _mint_token(user_id: str) -> str:
settings = Settings()
return mint_access_token(
return create_session_cookie(
settings=settings,
subject=user_id,
email="test@headquarter.local",
name="Test User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
)
@@ -0,0 +1,188 @@
import uuid
import pytest
from fastapi.testclient import TestClient
@pytest.mark.integration
class TestToolTypesAPIExtended:
"""Integration tests for tool types API with new fields."""
def test_create_tool_type_with_dockerfile(self, authenticated_client: TestClient) -> None:
"""Test creating a tool type with dockerfile definition."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "dockerfile-tool",
"display_name": "Dockerfile Tool",
"category": "utility",
"interfaces": ["terminal"],
"default_port": 8080,
"definition_type": "dockerfile",
"dockerfile_template": "FROM python:3.11\nRUN pip install flask",
"required_variables": [],
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "dockerfile-tool"
assert data["definition_type"] == "dockerfile"
assert data["dockerfile_template"] == "FROM python:3.11\nRUN pip install flask"
def test_create_tool_type_with_readiness_probe(self, authenticated_client: TestClient) -> None:
"""Test creating a tool type with readiness probe."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "probed-tool",
"display_name": "Probed Tool",
"category": "utility",
"interfaces": ["web"],
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"readiness_probe": {
"command": "curl -f http://localhost:8080",
"timeout": 30,
"interval": 2,
},
"required_variables": [],
},
)
assert response.status_code == 201
data = response.json()
assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080"
assert data["readiness_probe"]["timeout"] == 30
def test_create_tool_type_invalid_definition_type(self, authenticated_client: TestClient) -> None:
"""Test that invalid definition types are rejected."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "invalid-tool",
"display_name": "Invalid Tool",
"default_port": 8080,
"definition_type": "invalid",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
assert response.status_code == 422
def test_create_tool_type_dockerfile_without_template(self, authenticated_client: TestClient) -> None:
"""Test that dockerfile type requires dockerfile_template."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "no-dockerfile",
"display_name": "No Dockerfile",
"default_port": 8080,
"definition_type": "dockerfile",
"required_variables": [],
},
)
assert response.status_code == 422
def test_update_tool_type_with_new_fields(self, authenticated_client: TestClient) -> None:
"""Test updating a tool type with new fields."""
# Create tool type first
create_response = authenticated_client.post(
"/tool-types",
json={
"name": "update-test-tool",
"display_name": "Update Test Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = create_response.json()["id"]
# Update it
response = authenticated_client.put(
f"/tool-types/{tool_id}",
json={
"display_name": "Updated Name",
"readiness_probe": {
"command": "curl -f http://localhost:8080/health",
"timeout": 60,
"interval": 5,
},
},
)
assert response.status_code == 200
data = response.json()
assert data["display_name"] == "Updated Name"
assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080/health"
def test_validate_tool_type_compose(self, authenticated_client: TestClient) -> None:
"""Test validating compose template."""
response = authenticated_client.post(
"/tool-types/validate",
json={
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
},
)
assert response.status_code == 200
data = response.json()
assert data["valid"] is True
def test_validate_tool_type_invalid_compose(self, authenticated_client: TestClient) -> None:
"""Test validating invalid compose template."""
response = authenticated_client.post(
"/tool-types/validate",
json={
"definition_type": "compose",
"compose_template": "invalid: yaml: [",
},
)
assert response.status_code == 200
data = response.json()
assert data["valid"] is False
assert "errors" in data
def test_validate_tool_type_dockerfile(self, authenticated_client: TestClient) -> None:
"""Test validating dockerfile template."""
response = authenticated_client.post(
"/tool-types/validate",
json={
"definition_type": "dockerfile",
"dockerfile_template": "FROM python:3.11\nRUN pip install flask",
},
)
assert response.status_code == 200
data = response.json()
assert data["valid"] is True
def test_get_tool_type_returns_new_fields(self, authenticated_client: TestClient) -> None:
"""Test that GET returns new fields."""
# Create tool type with all fields
create_response = authenticated_client.post(
"/tool-types",
json={
"name": "full-tool",
"display_name": "Full Tool",
"category": "editor",
"interfaces": ["web", "terminal"],
"default_port": 8443,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
"readiness_probe": {
"command": "curl -f http://localhost:8443",
"timeout": 30,
"interval": 2,
},
"required_variables": ["REPO_PATH"],
},
)
tool_id = create_response.json()["id"]
# Get it
response = authenticated_client.get(f"/tool-types/{tool_id}")
assert response.status_code == 200
data = response.json()
assert data["definition_type"] == "compose"
assert data["category"] == "editor"
assert data["interfaces"] == ["web", "terminal"]
assert "readiness_probe" in data
+4 -4
View File
@@ -1,5 +1,5 @@
import uuid
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
import asyncio
import io
@@ -8,7 +8,7 @@ import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from src.auth.jwt_service import mint_access_token
from src.auth.session import create_session_cookie
from src.config import Settings, build_database_url
from src.models import Base
from src.models.user import User
@@ -78,12 +78,12 @@ def _insert_test_user(user_id: str) -> None:
def _create_auth_cookie(user_id: str) -> str:
settings = Settings()
return mint_access_token(
return create_session_cookie(
settings=settings,
subject=user_id,
email="test@headquarter.local",
name="Test User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
)
+151
View File
@@ -0,0 +1,151 @@
"""Unit tests for docker build service."""
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from src.services.docker_build import build_image
class TestBuildImage:
"""Tests for build_image function."""
@patch("subprocess.run")
def test_builds_image_successfully(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="Successfully built abc123",
stderr="",
)
with tempfile.TemporaryDirectory() as tmpdir:
result = build_image(tmpdir, "FROM python:3.11", "test-image:latest")
assert result[0] == 0
assert "Successfully built" in result[1]
mock_run.assert_called_once()
call_args = mock_run.call_args
assert "test-image:latest" in call_args[0][0]
assert "build" in call_args[0][0]
@patch("subprocess.run")
def test_build_fails(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=1,
stdout="",
stderr="Error: failed to build",
)
with tempfile.TemporaryDirectory() as tmpdir:
result = build_image(tmpdir, "FROM invalid:image", "test-image:latest")
assert result[0] == 1
assert "failed to build" in result[2]
@patch("subprocess.run")
def test_build_with_tag(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="",
stderr="",
)
with tempfile.TemporaryDirectory() as tmpdir:
build_image(tmpdir, "FROM python:3.11", "my-registry/tool:v1.0")
call_args = mock_run.call_args[0][0]
assert "my-registry/tool:v1.0" in call_args
@patch("subprocess.run")
def test_build_command_structure(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
build_image(tmpdir, "FROM python:3.11", "test:latest")
cmd = mock_run.call_args[0][0]
assert cmd[0] == "docker"
assert cmd[1] == "build"
assert "-t" in cmd
assert "test:latest" in cmd
assert tmpdir in cmd
@patch("subprocess.run")
def test_build_writes_dockerfile(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
dockerfile_content = "FROM python:3.11\\nRUN pip install flask"
build_image(tmpdir, dockerfile_content, "test:latest")
dockerfile_path = Path(tmpdir) / "Dockerfile"
assert dockerfile_path.exists()
assert dockerfile_path.read_text() == dockerfile_content
@patch("subprocess.run")
def test_build_writes_context_files(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
build_context = {
"requirements.txt": "flask==2.0\\nnumpy==1.21",
"app.py": "from flask import Flask\\napp = Flask(__name__)",
}
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
req_path = Path(tmpdir) / "requirements.txt"
app_path = Path(tmpdir) / "app.py"
assert req_path.exists()
assert req_path.read_text() == "flask==2.0\\nnumpy==1.21"
assert app_path.exists()
assert app_path.read_text() == "from flask import Flask\\napp = Flask(__name__)"
@patch("subprocess.run")
def test_build_creates_nested_directories(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
build_context = {
"src/app.py": "print('hello')",
}
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
app_path = Path(tmpdir) / "src" / "app.py"
assert app_path.exists()
@patch("subprocess.run")
def test_build_prevents_path_traversal(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
build_context = {
"../../../etc/passwd": "root:x:0:0",
}
with pytest.raises(ValueError, match="escapes instance directory"):
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
mock_run.assert_not_called()
@patch("subprocess.run")
def test_build_timeout(self, mock_run) -> None:
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["docker", "build"], timeout=300)
with tempfile.TemporaryDirectory() as tmpdir:
result = build_image(tmpdir, "FROM python:3.11", "test:latest")
assert result[0] == 1
assert "timed out" in result[2].lower()
@patch("subprocess.run")
def test_build_exception(self, mock_run) -> None:
mock_run.side_effect = OSError("Docker not available")
with tempfile.TemporaryDirectory() as tmpdir:
result = build_image(tmpdir, "FROM python:3.11", "test:latest")
assert result[0] == 1
assert "Docker not available" in result[2]
@@ -39,3 +39,18 @@ def test_refresh_tokens_migration_has_expected_revision_chain() -> None:
assert module.revision == "0002_refresh_tokens"
assert module.down_revision == "0001_initial_schema"
@pytest.mark.unit
def test_config_profiles_migration_has_expected_revision_chain() -> None:
migration_path = Path(__file__).resolve().parents[2] / "alembic" / "versions" / "0013_add_config_profiles.py"
spec = spec_from_file_location("add_config_profiles", migration_path)
assert spec is not None
assert spec.loader is not None
module = module_from_spec(spec)
spec.loader.exec_module(module)
assert module.revision == "0013_add_config_profiles"
assert module.down_revision == "0012_default_port_req"
@@ -0,0 +1,463 @@
"""Unit tests for the profile resolver service."""
import uuid
from unittest.mock import MagicMock
import pytest
from src.services.profile_resolver import (
ProfileCycleError,
ResolvedProfileOutput,
resolve_profile,
)
def _make_profile(
name: str,
env_vars: dict[str, str] | None = None,
start_command: str | None = None,
working_directory: str | None = None,
port: int | None = None,
mounts: list[MagicMock] | None = None,
includes: list[MagicMock] | None = None,
) -> MagicMock:
"""Create a mock ConfigProfile for testing."""
profile = MagicMock()
profile.id = uuid.uuid4()
profile.name = name
profile.environment_variables = env_vars or {}
profile.start_command = start_command
profile.working_directory = working_directory
profile.port = port
profile.mounts = mounts or []
profile.includes = includes or []
return profile
def _make_include(included_profile: MagicMock, order_index: int = 0) -> MagicMock:
"""Create a mock ConfigInclude for testing."""
include = MagicMock()
include.included_profile = included_profile
include.order_index = order_index
return include
def _make_mount(
target_path: str,
mode: str = "rw",
files: dict[str, str] | None = None,
order_index: int = 0,
) -> MagicMock:
"""Create a mock ConfigMount for testing."""
mount = MagicMock()
mount.target_path = target_path
mount.mode = mode
mount.files = files or {}
mount.order_index = order_index
return mount
class TestResolveProfileBasic:
"""Tests for basic profile resolution without includes."""
def test_empty_profile(self) -> None:
"""Resolving an empty profile returns empty output."""
profile = _make_profile("empty")
result = resolve_profile(profile)
assert isinstance(result, ResolvedProfileOutput)
assert result.profile_name == "empty"
assert result.environment_variables == {}
assert result.runtime_hints.start_command is None
assert result.runtime_hints.working_directory is None
assert result.runtime_hints.port is None
assert result.mounts == {}
assert result.resolution_order == ["empty"]
def test_env_vars_only(self) -> None:
"""Profile with env vars resolves correctly."""
profile = _make_profile(
"env-only",
env_vars={"FOO": "bar", "BAZ": "qux"},
)
result = resolve_profile(profile)
assert result.environment_variables == {"FOO": "bar", "BAZ": "qux"}
assert result.env_var_sources == {
"FOO": ["env-only"],
"BAZ": ["env-only"],
}
def test_runtime_hints_only(self) -> None:
"""Profile with runtime hints resolves correctly."""
profile = _make_profile(
"hints-only",
start_command="python app.py",
working_directory="/app",
port=8080,
)
result = resolve_profile(profile)
assert result.runtime_hints.start_command == "python app.py"
assert result.runtime_hints.working_directory == "/app"
assert result.runtime_hints.port == 8080
assert result.runtime_hints.overridden_hints == {
"start_command": "hints-only",
"working_directory": "hints-only",
"port": "hints-only",
}
def test_mounts_only(self) -> None:
"""Profile with mounts resolves correctly."""
profile = _make_profile(
"mounts-only",
mounts=[
_make_mount(
"/config",
mode="ro",
files={"settings.json": '{"key": "value"}'},
),
],
)
result = resolve_profile(profile)
assert "/config" in result.mounts
mount = result.mounts["/config"]
assert mount.target_path == "/config"
assert mount.mode == "ro"
assert mount.files == {"settings.json": '{"key": "value"}'}
class TestResolveProfileIncludes:
"""Tests for profile resolution with includes."""
def test_single_include(self) -> None:
"""Profile with one include resolves in correct order."""
base = _make_profile("base", env_vars={"FOO": "base"})
derived = _make_profile(
"derived",
env_vars={"BAR": "derived"},
includes=[_make_include(base, order_index=0)],
)
result = resolve_profile(derived)
assert result.resolution_order == ["derived", "base"]
assert result.environment_variables == {
"FOO": "base",
"BAR": "derived",
}
def test_multiple_includes_ordered(self) -> None:
"""Multiple includes are resolved in order_index order."""
first = _make_profile("first", env_vars={"KEY": "first"})
second = _make_profile("second", env_vars={"KEY": "second"})
main = _make_profile(
"main",
includes=[
_make_include(first, order_index=0),
_make_include(second, order_index=1),
],
)
result = resolve_profile(main)
assert result.resolution_order == ["main", "first", "second"]
# second overrides first
assert result.environment_variables == {"KEY": "second"}
assert result.env_var_sources["KEY"] == ["first", "second"]
def test_include_order_matters(self) -> None:
"""Changing include order changes resolution."""
a = _make_profile("a", env_vars={"KEY": "a"})
b = _make_profile("b", env_vars={"KEY": "b"})
main1 = _make_profile(
"main",
includes=[
_make_include(a, order_index=0),
_make_include(b, order_index=1),
],
)
main2 = _make_profile(
"main",
includes=[
_make_include(b, order_index=0),
_make_include(a, order_index=1),
],
)
result1 = resolve_profile(main1)
result2 = resolve_profile(main2)
assert result1.environment_variables["KEY"] == "b"
assert result2.environment_variables["KEY"] == "a"
def test_nested_includes(self) -> None:
"""Deeply nested includes resolve recursively."""
deep = _make_profile("deep", env_vars={"DEEP": "value"})
mid = _make_profile(
"mid",
env_vars={"MID": "value"},
includes=[_make_include(deep, order_index=0)],
)
top = _make_profile(
"top",
env_vars={"TOP": "value"},
includes=[_make_include(mid, order_index=0)],
)
result = resolve_profile(top)
assert result.resolution_order == ["top", "mid", "deep"]
assert result.environment_variables == {
"TOP": "value",
"MID": "value",
"DEEP": "value",
}
class TestResolveProfileOverrides:
"""Tests for deterministic override rules."""
def test_env_var_override(self) -> None:
"""Later layers override earlier env vars."""
base = _make_profile("base", env_vars={"KEY": "base"})
override = _make_profile("override", env_vars={"KEY": "override"})
main = _make_profile(
"main",
includes=[
_make_include(base, order_index=0),
_make_include(override, order_index=1),
],
)
result = resolve_profile(main)
assert result.environment_variables["KEY"] == "override"
assert result.env_var_sources["KEY"] == ["base", "override"]
def test_main_profile_wins_over_includes(self) -> None:
"""The main profile itself wins over all includes."""
base = _make_profile("base", env_vars={"KEY": "base"})
main = _make_profile(
"main",
env_vars={"KEY": "main"},
includes=[_make_include(base, order_index=0)],
)
result = resolve_profile(main)
assert result.environment_variables["KEY"] == "main"
assert result.env_var_sources["KEY"] == ["base", "main"]
def test_runtime_hint_override(self) -> None:
"""Later layers override earlier runtime hints."""
base = _make_profile("base", start_command="python old.py")
override = _make_profile("override", start_command="python new.py")
main = _make_profile(
"main",
includes=[
_make_include(base, order_index=0),
_make_include(override, order_index=1),
],
)
result = resolve_profile(main)
assert result.runtime_hints.start_command == "python new.py"
assert result.runtime_hints.overridden_hints["start_command"] == "override"
def test_mount_file_override(self) -> None:
"""Later layers override earlier files in the same mount."""
base = _make_profile(
"base",
mounts=[
_make_mount(
"/config",
files={"app.json": '{"v": 1}'},
),
],
)
override = _make_profile(
"override",
mounts=[
_make_mount(
"/config",
files={"app.json": '{"v": 2}'},
),
],
)
main = _make_profile(
"main",
includes=[
_make_include(base, order_index=0),
_make_include(override, order_index=1),
],
)
result = resolve_profile(main)
mount = result.mounts["/config"]
assert mount.files["app.json"] == '{"v": 2}'
assert mount.overridden_files["app.json"] == ["override"]
def test_mount_mode_override(self) -> None:
"""Later layers override mount mode."""
base = _make_profile(
"base",
mounts=[_make_mount("/data", mode="ro")],
)
override = _make_profile(
"override",
mounts=[_make_mount("/data", mode="rw")],
)
main = _make_profile(
"main",
includes=[
_make_include(base, order_index=0),
_make_include(override, order_index=1),
],
)
result = resolve_profile(main)
assert result.mounts["/data"].mode == "rw"
assert result.mounts["/data"].mode_overridden_by == "override"
def test_mount_file_merge(self) -> None:
"""Different files in the same mount are merged."""
base = _make_profile(
"base",
mounts=[
_make_mount(
"/config",
files={"a.json": "1"},
),
],
)
override = _make_profile(
"override",
mounts=[
_make_mount(
"/config",
files={"b.json": "2"},
),
],
)
main = _make_profile(
"main",
includes=[
_make_include(base, order_index=0),
_make_include(override, order_index=1),
],
)
result = resolve_profile(main)
mount = result.mounts["/config"]
assert mount.files == {"a.json": "1", "b.json": "2"}
class TestResolveProfileCycles:
"""Tests for cycle detection during resolution."""
def test_direct_cycle(self) -> None:
"""A -> B -> A is detected."""
a = _make_profile("a")
b = _make_profile("b", includes=[_make_include(a, order_index=0)])
a.includes = [_make_include(b, order_index=0)]
with pytest.raises(ProfileCycleError) as exc_info:
resolve_profile(a)
assert "a" in exc_info.value.cycle_path
assert "b" in exc_info.value.cycle_path
def test_indirect_cycle(self) -> None:
"""A -> B -> C -> A is detected."""
a = _make_profile("a")
c = _make_profile("c")
b = _make_profile("b", includes=[_make_include(c, order_index=0)])
a.includes = [_make_include(b, order_index=0)]
c.includes = [_make_include(a, order_index=0)]
with pytest.raises(ProfileCycleError) as exc_info:
resolve_profile(a)
assert "a" in exc_info.value.cycle_path
assert "b" in exc_info.value.cycle_path
assert "c" in exc_info.value.cycle_path
def test_self_cycle(self) -> None:
"""A -> A is detected."""
a = _make_profile("a")
a.includes = [_make_include(a, order_index=0)]
with pytest.raises(ProfileCycleError) as exc_info:
resolve_profile(a)
assert exc_info.value.cycle_path == ["a", "a"]
def test_cycle_does_not_partially_resolve(self) -> None:
"""Cycle detection prevents any partial resolution."""
a = _make_profile("a", env_vars={"A": "a"})
b = _make_profile("b", env_vars={"B": "b"})
a.includes = [_make_include(b, order_index=0)]
b.includes = [_make_include(a, order_index=0)]
with pytest.raises(ProfileCycleError):
resolve_profile(a)
class TestResolveProfileDiamond:
"""Tests for diamond-shaped include graphs."""
def test_diamond_resolution(self) -> None:
"""Diamond graph resolves correctly without duplication issues."""
base = _make_profile("base", env_vars={"BASE": "base"})
left = _make_profile(
"left",
env_vars={"LEFT": "left"},
includes=[_make_include(base, order_index=0)],
)
right = _make_profile(
"right",
env_vars={"RIGHT": "right"},
includes=[_make_include(base, order_index=0)],
)
top = _make_profile(
"top",
env_vars={"TOP": "top"},
includes=[
_make_include(left, order_index=0),
_make_include(right, order_index=1),
],
)
result = resolve_profile(top)
# base should appear once (via left, then right skips because visited)
assert result.resolution_order == ["top", "left", "base", "right"]
assert result.environment_variables == {
"TOP": "top",
"LEFT": "left",
"RIGHT": "right",
"BASE": "base",
}
def test_diamond_override(self) -> None:
"""Diamond graph with conflicting overrides resolves correctly."""
base = _make_profile("base", env_vars={"KEY": "base"})
left = _make_profile(
"left",
env_vars={"KEY": "left"},
includes=[_make_include(base, order_index=0)],
)
right = _make_profile(
"right",
env_vars={"KEY": "right"},
includes=[_make_include(base, order_index=0)],
)
top = _make_profile(
"top",
includes=[
_make_include(left, order_index=0),
_make_include(right, order_index=1),
],
)
result = resolve_profile(top)
# right wins because it's later
assert result.environment_variables["KEY"] == "right"
assert result.env_var_sources["KEY"] == ["base", "left", "right"]
# Note: base appears once because visited set skips duplicate resolution in diamond graphs
+217
View File
@@ -0,0 +1,217 @@
"""Unit tests for readiness probe service."""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from src.services.readiness_probe import execute_probe
class TestExecuteProbe:
"""Tests for execute_probe function."""
@patch("subprocess.run")
async def test_probe_succeeds_first_attempt(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="healthy",
stderr="",
)
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080")
assert result is True
assert any("Success" in log for log in logs)
mock_run.assert_called_once_with(
["docker", "exec", "container-123", "sh", "-c", "curl -f http://localhost:8080"],
capture_output=True,
text=True,
timeout=2,
)
@patch("subprocess.run")
async def test_probe_fails_then_succeeds(self, mock_run) -> None:
mock_run.side_effect = [
MagicMock(returncode=1, stdout="", stderr="Connection refused"),
MagicMock(returncode=1, stdout="", stderr="Connection refused"),
MagicMock(returncode=0, stdout="healthy", stderr=""),
]
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080", timeout=10, interval=0.1)
assert result is True
assert mock_run.call_count == 3
assert any("Attempt 1: Failed" in log for log in logs)
assert any("Attempt 3: Success" in log for log in logs)
@patch("subprocess.run")
async def test_probe_times_out(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=1,
stdout="",
stderr="Connection refused",
)
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080", timeout=0.5, interval=0.2)
assert result is False
assert any("timed out" in log.lower() for log in logs)
@patch("subprocess.run")
async def test_probe_command_not_found(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=127,
stdout="",
stderr="command not found",
)
result, logs = await execute_probe("container-123", "nonexistent-command", timeout=1, interval=0.3)
assert result is False
assert any("exit code 127" in log for log in logs)
@patch("subprocess.run")
async def test_probe_exception(self, mock_run) -> None:
mock_run.side_effect = OSError("Docker not available")
result, logs = await execute_probe("container-123", "curl http://localhost", timeout=1, interval=0.3)
assert result is False
assert any("Error" in log for log in logs)
@patch("subprocess.run")
async def test_probe_with_special_characters(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="",
stderr="",
)
cmd = "bash -c 'echo \"hello world\" && exit 0'"
await execute_probe("container-123", cmd)
call_args = mock_run.call_args
assert cmd in call_args[0][0]
@patch("subprocess.run")
async def test_probe_captures_stdout(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="Server is ready\\nVersion: 1.0",
stderr="",
)
result, logs = await execute_probe("container-123", "cat /app/status")
assert result is True
assert any("Server is ready" in log for log in logs)
class TestIntegrationScenarios:
"""Integration-style tests with realistic scenarios."""
@patch("subprocess.run")
async def test_web_server_probe(self, mock_run) -> None:
"""Test typical web server health check."""
mock_run.side_effect = [
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=0, stdout="OK", stderr=""),
]
result, logs = await execute_probe(
"web-container",
"curl -f http://localhost:8080/health",
timeout=10,
interval=0.2,
)
assert result is True
assert mock_run.call_count == 4
@patch("subprocess.run")
async def test_command_probe(self, mock_run) -> None:
"""Test command availability check."""
mock_run.return_value = MagicMock(
returncode=0,
stdout="opencode 1.0.0",
stderr="",
)
result, logs = await execute_probe(
"tool-container",
"which opencode && opencode --version",
timeout=30,
interval=2,
)
assert result is True
assert any("opencode 1.0.0" in log for log in logs)
@patch("subprocess.run")
async def test_database_probe(self, mock_run) -> None:
"""Test database readiness check."""
mock_run.side_effect = [
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=0, stdout="/var/run/postgresql:5432 - accepting connections", stderr=""),
]
result, logs = await execute_probe(
"db-container",
"pg_isready -U postgres",
timeout=10,
interval=0.3,
)
assert result is True
assert mock_run.call_count == 3
@patch("subprocess.run")
async def test_file_probe(self, mock_run) -> None:
"""Test file existence check."""
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
result, logs = await execute_probe(
"app-container",
"[ -f /app/ready ]",
timeout=10,
interval=1,
)
assert result is True
@patch("subprocess.run")
async def test_slow_starting_service(self, mock_run) -> None:
"""Test service that takes time to start."""
# Simulate 5 failures before success
side_effects = [MagicMock(returncode=1, stdout="", stderr="")] * 5
side_effects.append(MagicMock(returncode=0, stdout="Ready", stderr=""))
mock_run.side_effect = side_effects
result, logs = await execute_probe(
"slow-container",
"curl -f http://localhost:8080",
timeout=10,
interval=0.2,
)
assert result is True
assert mock_run.call_count == 6
assert any("Attempt 6: Success" in log for log in logs)
@patch("subprocess.run")
async def test_zero_timeout_immediate_return(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
result, logs = await execute_probe(
"container",
"test",
timeout=0,
interval=1,
)
assert result is False
assert any("timed out" in log.lower() for log in logs)
+1371
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Headquarter</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
+796
View File
@@ -0,0 +1,796 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Headquarter - UI Preview</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f4f1ea;
--panel: #fffef9;
--ink: #1d1d1b;
--muted: #5f5b55;
--brand: #275d4b;
--brand-strong: #154236;
--border: #d8d0c5;
--primary: #275d4b;
--primary-fg: #fffef9;
--color-primary: #275d4b;
--success: #2f8f62;
--success-light: rgba(47, 143, 98, 0.14);
--warning: #c08a1e;
--warning-light: rgba(192, 138, 30, 0.14);
--danger: #b94a3c;
--danger-light: rgba(185, 74, 60, 0.14);
--info: #4f7fb8;
--info-light: rgba(79, 127, 184, 0.14);
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.5rem;
--space-6: 2rem;
--font-size-xs: clamp(0.625rem, 0.6rem + 0.125vw, 0.75rem);
--font-size-sm: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
--font-size-base: clamp(0.875rem, 0.8rem + 0.35vw, 1rem);
--font-size-lg: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
--font-size-xl: clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
}
/* App Shell */
.shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.shell-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.85rem 1.25rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--panel) 88%, transparent);
backdrop-filter: blur(7px);
}
.brand {
font-weight: 700;
letter-spacing: 0.02em;
color: var(--ink);
text-decoration: none;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.user-chip {
border: 1px solid var(--border);
background: var(--panel);
border-radius: 999px;
padding: 0.35rem 0.7rem;
font-size: 0.9rem;
color: var(--ink);
text-decoration: none;
}
.ghost-button {
border: 1px solid var(--border);
background: transparent;
border-radius: 10px;
padding: 0.58rem 0.85rem;
cursor: pointer;
font: inherit;
color: var(--muted);
}
.shell-body {
display: grid;
grid-template-columns: 230px 1fr;
min-height: calc(100vh - 57px);
}
.shell-nav {
border-right: 1px solid var(--border);
padding: 1rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
background: color-mix(in srgb, var(--panel) 65%, transparent);
}
.nav-item {
padding: 0.65rem 0.75rem;
border-radius: 10px;
color: var(--muted);
text-decoration: none;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.95rem;
}
.nav-item:hover {
background: #ece7df;
color: var(--ink);
}
.nav-item-active {
background: var(--brand);
color: #f7fff7;
}
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: var(--primary);
color: var(--primary-fg);
border-radius: 9px;
font-size: 11px;
font-weight: 600;
margin-left: auto;
}
.nav-divider {
height: 1px;
background: var(--border);
margin: 0.5rem 0;
}
.nav-section-title {
margin-top: 0.5rem;
padding: 0.25rem 0.75rem;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.session-item {
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
}
.session-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
display: inline-block;
margin-right: 0.25rem;
}
.session-status.running {
background: var(--success);
}
.shell-content {
padding: 1.25rem;
overflow-x: hidden;
}
/* Common Components */
.stack {
display: flex;
flex-direction: column;
gap: 1rem;
}
.stack-sm {
gap: 0.5rem;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1rem;
}
.muted {
color: var(--muted);
}
.eyebrow {
margin: 0;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.primary-button {
background: var(--brand);
color: white;
border-radius: 10px;
border: 1px solid transparent;
padding: 0.58rem 0.85rem;
cursor: pointer;
font: inherit;
}
.primary-button:hover {
background: var(--brand-strong);
}
.secondary-button {
border-color: var(--border);
background: var(--panel);
border-radius: 10px;
border: 1px solid var(--border);
padding: 0.58rem 0.85rem;
cursor: pointer;
font: inherit;
}
/* Home Page */
.home-page {
max-width: 1240px;
}
.home-hero {
display: flex;
justify-content: space-between;
gap: var(--space-4);
align-items: flex-start;
}
.home-hero-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.home-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-4);
}
.home-summary-card .card-label {
margin: 0;
color: var(--muted);
font-size: 0.875rem;
}
.home-summary-card .card-value {
margin: 0.45rem 0 0;
font-size: 1.6rem;
font-weight: 700;
}
.home-section h2,
.home-section h3 {
margin: 0;
}
.home-session-grid,
.home-project-grid {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.session-card {
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
}
.session-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
text-transform: capitalize;
}
.status-badge.running {
background: var(--success-light);
color: var(--success);
}
.status-badge.building {
background: var(--warning-light);
color: var(--warning);
}
.status-badge.pending {
background: var(--info-light);
color: var(--info);
}
.recent-sessions-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.recent-session-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) var(--space-4);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
}
.recent-session-name {
font-weight: 500;
}
.create-session-form .form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-4);
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.form-field input,
.form-field select,
.form-field textarea {
padding: 0.55rem 0.7rem;
border: 1px solid var(--border);
border-radius: 10px;
font: inherit;
background: var(--panel);
color: var(--ink);
}
.form-actions {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
/* Settings Page */
.settings-page {
max-width: 1240px;
}
.settings-header {
padding: 1.5rem;
}
.settings-tabs {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.settings-tab {
padding: 0.6rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--muted);
background: var(--panel);
text-decoration: none;
cursor: pointer;
}
.settings-tab.active {
background: var(--brand);
color: white;
border-color: transparent;
}
.settings-panel {
padding: 1.5rem;
}
.settings-actions {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.success-text {
color: var(--success);
}
.error-text {
color: var(--danger);
}
/* Preview Switcher */
.preview-switcher {
position: fixed;
bottom: 1rem;
right: 1rem;
display: flex;
gap: 0.5rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.preview-switcher button {
padding: 0.5rem 1rem;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
font: inherit;
color: var(--muted);
}
.preview-switcher button.active {
background: var(--brand);
color: white;
}
.page-preview {
display: none;
}
.page-preview.active {
display: block;
}
/* Responsive */
@media (max-width: 767px) {
.shell-body {
grid-template-columns: 1fr;
}
.shell-nav {
flex-direction: row;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border);
}
.home-hero {
flex-direction: column;
}
}
</style>
</head>
<body>
<div class="shell">
<header class="shell-header">
<a href="#" class="brand">Headquarter</a>
<div class="header-actions">
<a href="#" class="user-chip">User</a>
<button class="ghost-button">Logout</button>
</div>
</header>
<div class="shell-body">
<aside class="shell-nav" aria-label="Primary navigation">
<a href="#" class="nav-item nav-item-active">
<span>🏠</span> Home
<span class="nav-badge">3</span>
</a>
<a href="#" class="nav-item">
<span>📁</span> Projects
</a>
<a href="#" class="nav-item">
<span>⚙️</span> Settings
</a>
<div class="nav-divider"></div>
<div class="nav-section-title">Live sessions</div>
<a href="#" class="nav-item session-item">
<span class="session-status running"></span>
<span>Dev Environment</span>
</a>
<a href="#" class="nav-item session-item">
<span class="session-status running"></span>
<span>Jupyter Lab</span>
</a>
<a href="#" class="nav-item session-item">
<span class="session-status"></span>
<span>Code Server</span>
</a>
</aside>
<main class="shell-content">
<!-- HOME PAGE PREVIEW -->
<div id="home-preview" class="page-preview active">
<section class="stack home-page">
<header class="home-hero card">
<div class="stack-sm">
<p class="eyebrow">Workspace overview</p>
<h1>Home</h1>
<p class="muted">Open sessions, available projects, and the fastest path back into work.</p>
</div>
<div class="home-hero-actions">
<button class="primary-button">New Project</button>
<button class="secondary-button">Settings</button>
</div>
</header>
<div class="home-summary-grid">
<article class="card home-summary-card">
<p class="card-label">Open sessions</p>
<p class="card-value">3</p>
</article>
<article class="card home-summary-card">
<p class="card-label">Projects</p>
<p class="card-value">5</p>
</article>
<article class="card home-summary-card">
<p class="card-label">Repositories</p>
<p class="card-value">12</p>
</article>
</div>
<section class="card stack home-section">
<div class="page-header">
<div>
<p class="eyebrow">Open sessions</p>
<h2>3</h2>
</div>
</div>
<div class="home-session-grid">
<article class="card session-card">
<div class="stack-sm">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<h3>Dev Environment</h3>
<span class="status-badge running">running</span>
</div>
<p class="muted">Acme Corp · main</p>
<p class="muted">VS Code Server</p>
</div>
<div class="session-actions">
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
</div>
</article>
<article class="card session-card">
<div class="stack-sm">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<h3>Jupyter Lab</h3>
<span class="status-badge running">running</span>
</div>
<p class="muted">Data Science · experiments</p>
<p class="muted">Jupyter Notebook</p>
</div>
<div class="session-actions">
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
</div>
</article>
<article class="card session-card">
<div class="stack-sm">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<h3>Database Console</h3>
<span class="status-badge building">building</span>
</div>
<p class="muted">Backend API · staging</p>
<p class="muted">PostgreSQL Client</p>
</div>
<div class="session-actions">
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
</div>
</article>
</div>
</section>
<section class="card stack home-section">
<div class="page-header">
<div>
<p class="eyebrow">Available projects</p>
<h2>5</h2>
</div>
<button class="secondary-button">View all</button>
</div>
<div class="home-project-grid">
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
<div class="stack-sm">
<h3>Acme Corp</h3>
<p class="muted">Main product development</p>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
</article>
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
<div class="stack-sm">
<h3>Data Science</h3>
<p class="muted">ML experiments and notebooks</p>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
</article>
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
<div class="stack-sm">
<h3>Backend API</h3>
<p class="muted">REST API services</p>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
</article>
</div>
</section>
<section class="card stack home-section">
<div class="page-header">
<div>
<p class="eyebrow">Quick create</p>
<h2>Start a session</h2>
</div>
</div>
<form class="stack create-session-form">
<div class="form-row">
<label class="form-field">
Project
<select>
<option>Select project...</option>
<option>Acme Corp</option>
<option>Data Science</option>
</select>
</label>
<label class="form-field">
Repository
<select disabled>
<option>Select repository...</option>
</select>
</label>
<label class="form-field">
Tool type
<select>
<option>Select tool...</option>
<option>VS Code Server</option>
<option>Jupyter Lab</option>
</select>
</label>
</div>
<label class="form-field">
Display name
<input type="text" placeholder="My Development Environment">
</label>
<div class="form-actions">
<button class="primary-button" type="submit">Create Session</button>
</div>
</form>
</section>
<section class="card stack home-section">
<div class="page-header">
<div>
<p class="eyebrow">Recent sessions</p>
<h2>2</h2>
</div>
</div>
<div class="recent-sessions-list">
<article class="recent-session-item">
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span class="recent-session-name">Old Dev Box</span>
<span class="muted">Acme Corp · VS Code Server</span>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
</article>
<article class="recent-session-item">
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span class="recent-session-name">ML Training</span>
<span class="muted">Data Science · Jupyter Lab</span>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
</article>
</div>
</section>
</section>
</div>
<!-- SETTINGS PAGE PREVIEW -->
<div id="settings-preview" class="page-preview">
<section class="stack settings-page">
<header class="settings-header card stack-sm">
<div>
<p class="eyebrow">Configuration</p>
<h1>Settings</h1>
</div>
<p class="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
</header>
<nav class="settings-tabs" aria-label="Settings sections">
<a href="#" class="settings-tab active">General</a>
<a href="#" class="settings-tab">SSH Keys</a>
<a href="#" class="settings-tab">Tool Types</a>
<a href="#" class="settings-tab">Tool Configs</a>
</nav>
<div class="settings-panel card">
<div class="stack">
<h2>General</h2>
<label class="form-field">
Theme
<select>
<option>System</option>
<option>Light</option>
<option>Dark</option>
</select>
</label>
<label class="form-field">
Git user name
<input type="text" placeholder="Your git commit name" value="John Doe">
</label>
<label class="form-field">
Git user email
<input type="email" placeholder="your.email@example.com" value="john@example.com">
</label>
<label class="form-field">
Default editor
<input type="text" placeholder="e.g., vscode, vim, cursor" value="vscode">
</label>
<div class="settings-actions">
<button class="primary-button">Save Settings</button>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
</div>
<div class="preview-switcher">
<button class="active" onclick="showPage('home')">Home</button>
<button onclick="showPage('settings')">Settings</button>
</div>
<script>
function showPage(page) {
document.querySelectorAll('.page-preview').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.preview-switcher button').forEach(b => b.classList.remove('active'));
document.getElementById(page + '-preview').classList.add('active');
event.target.classList.add('active');
}
</script>
</body>
</html>
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from "vitest";
import {
createConfigFolder,
deleteConfigFolder,
listConfigFolders,
updateConfigFolder,
} from "../api/config_folders";
const mockGet = vi.fn();
const mockPost = vi.fn();
const mockPut = vi.fn();
const mockDelete = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
interceptors: {
response: {
use: vi.fn(),
},
},
},
shouldSkipAuthRedirect: vi.fn(() => false),
}));
describe("config_folders API", () => {
describe("listConfigFolders", () => {
it("returns folders with files and overrides", async () => {
const mockResponse = {
data: [
{
id: "folder-1",
name: "my-dotfiles",
description: "My personal config files",
mount_path: "/home/user",
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
project_overrides: {},
is_active: true,
user_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
],
};
mockGet.mockResolvedValue(mockResponse);
const result = await listConfigFolders();
expect(result[0].name).toBe("my-dotfiles");
expect(result[0].files).toEqual({ ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" });
expect(mockGet).toHaveBeenCalledWith("/config-folders");
});
});
describe("createConfigFolder", () => {
it("creates folder with files", async () => {
const mockResponse = {
data: {
id: "folder-new",
name: "new-folder",
mount_path: "/workspace",
files: { ".env": "API_URL=http://localhost" },
is_active: true,
user_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
};
mockPost.mockResolvedValue(mockResponse);
const result = await createConfigFolder({
name: "new-folder",
mount_path: "/workspace",
files: { ".env": "API_URL=http://localhost" },
});
expect(result.name).toBe("new-folder");
expect(mockPost).toHaveBeenCalledWith(
"/config-folders",
expect.objectContaining({
name: "new-folder",
mount_path: "/workspace",
})
);
});
});
describe("updateConfigFolder", () => {
it("updates folder files", async () => {
const mockResponse = {
data: {
id: "folder-1",
name: "updated-folder",
mount_path: "/home/user",
files: { ".bashrc": "alias ll='ls -la'" },
is_active: true,
user_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
};
mockPut.mockResolvedValue(mockResponse);
const result = await updateConfigFolder("folder-1", {
files: { ".bashrc": "alias ll='ls -la'" },
});
expect(result.files).toEqual({ ".bashrc": "alias ll='ls -la'" });
expect(mockPut).toHaveBeenCalledWith(
"/config-folders/folder-1",
expect.objectContaining({
files: { ".bashrc": "alias ll='ls -la'" },
})
);
});
});
describe("deleteConfigFolder", () => {
it("deletes folder", async () => {
mockDelete.mockResolvedValue({ data: undefined });
await deleteConfigFolder("folder-1");
expect(mockDelete).toHaveBeenCalledWith("/config-folders/folder-1");
});
});
});
+95
View File
@@ -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<string, string>;
project_overrides: Record<string, { mount_path?: string; files?: Record<string, string> }> | null;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface CreateConfigFolderRequest {
name: string;
description?: string;
mount_path: string;
files?: Record<string, string>;
is_active?: boolean;
}
export interface UpdateConfigFolderRequest {
name?: string;
description?: string;
mount_path?: string;
files?: Record<string, string>;
is_active?: boolean;
}
export interface ProjectOverrideRequest {
mount_path?: string;
files?: Record<string, string>;
}
export const listConfigFolders = async (): Promise<ConfigFolder[]> => {
const response = await apiClient.get<ConfigFolder[]>("/config-folders");
return response.data;
};
export const getConfigFolder = async (id: string): Promise<ConfigFolder> => {
const response = await apiClient.get<ConfigFolder>(`/config-folders/${id}`);
return response.data;
};
export const createConfigFolder = async (
data: CreateConfigFolderRequest
): Promise<ConfigFolder> => {
const response = await apiClient.post<ConfigFolder>("/config-folders", data);
return response.data;
};
export const updateConfigFolder = async (
id: string,
data: UpdateConfigFolderRequest
): Promise<ConfigFolder> => {
const response = await apiClient.put<ConfigFolder>(`/config-folders/${id}`, data);
return response.data;
};
export const deleteConfigFolder = async (id: string): Promise<void> => {
await apiClient.delete(`/config-folders/${id}`);
};
export const addProjectOverride = async (
id: string,
projectId: string,
data: ProjectOverrideRequest
): Promise<ConfigFolder> => {
const response = await apiClient.post<ConfigFolder>(
`/config-folders/${id}/overrides/${projectId}`,
data
);
return response.data;
};
export const updateProjectOverride = async (
id: string,
projectId: string,
data: ProjectOverrideRequest
): Promise<ConfigFolder> => {
const response = await apiClient.put<ConfigFolder>(
`/config-folders/${id}/overrides/${projectId}`,
data
);
return response.data;
};
export const deleteProjectOverride = async (
id: string,
projectId: string
): Promise<void> => {
await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`);
};
+19
View File
@@ -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<string, string> | 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<string, string>;
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<void> => {
await apiClient.delete(`/tool-configs/${id}`);
};
export const getToolConfigDefaults = async (
toolTypeId: string
): Promise<ToolConfig> => {
const response = await apiClient.get<ToolConfig>(
`/tool-configs/defaults/${toolTypeId}`
);
return response.data;
};
+227
View File
@@ -0,0 +1,227 @@
import { describe, expect, it, vi } from "vitest";
import {
createToolType,
deleteToolType,
listToolTypes,
updateToolType,
validateToolType,
} from "../api/tool_types";
const mockGet = vi.fn();
const mockPost = vi.fn();
const mockPut = vi.fn();
const mockDelete = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
interceptors: {
response: {
use: vi.fn(),
},
},
},
shouldSkipAuthRedirect: vi.fn(() => false),
}));
describe("tool_types API", () => {
describe("listToolTypes", () => {
it("returns tool types with new fields", async () => {
const mockResponse = {
data: [
{
id: "type-1",
name: "custom-tool",
display_name: "Custom Tool",
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11",
readiness_probe: {
command: "python --version",
timeout: 30,
interval: 2,
},
build_context: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
],
};
mockGet.mockResolvedValue(mockResponse);
const result = await listToolTypes();
expect(result[0].definition_type).toBe("dockerfile");
expect(result[0].dockerfile_template).toBe("FROM python:3.11");
expect(result[0].readiness_probe).toEqual({
command: "python --version",
timeout: 30,
interval: 2,
});
});
it("returns compose tool types", async () => {
const mockResponse = {
data: [
{
id: "type-1",
name: "code-server",
definition_type: "compose",
compose_template: "version: '3.8'",
dockerfile_template: null,
build_context: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
],
};
mockGet.mockResolvedValue(mockResponse);
const result = await listToolTypes();
expect(result[0].definition_type).toBe("compose");
expect(result[0].dockerfile_template).toBeNull();
});
});
describe("createToolType", () => {
it("creates tool type with dockerfile", async () => {
const mockResponse = {
data: {
id: "new-type",
name: "docker-tool",
definition_type: "dockerfile",
dockerfile_template: "FROM node:18",
build_context: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
};
mockPost.mockResolvedValue(mockResponse);
const result = await createToolType({
name: "docker-tool",
display_name: "Docker Tool",
definition_type: "dockerfile",
dockerfile_template: "FROM node:18",
default_port: 3000,
required_variables: [],
});
expect(result.definition_type).toBe("dockerfile");
expect(mockPost).toHaveBeenCalledWith(
"/tool-types",
expect.objectContaining({
definition_type: "dockerfile",
dockerfile_template: "FROM node:18",
})
);
});
it("creates tool type with readiness probe", async () => {
const mockResponse = {
data: {
id: "new-type",
name: "probed-tool",
readiness_probe: {
command: "curl -f http://localhost:8080",
timeout: 60,
interval: 3,
},
build_context: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
};
mockPost.mockResolvedValue(mockResponse);
const result = await createToolType({
name: "probed-tool",
display_name: "Probed Tool",
compose_template: "version: '3.8'",
default_port: 8080,
required_variables: [],
readiness_probe: {
command: "curl -f http://localhost:8080",
timeout: 60,
interval: 3,
},
});
expect(result.readiness_probe).toEqual({
command: "curl -f http://localhost:8080",
timeout: 60,
interval: 3,
});
});
});
describe("validateToolType", () => {
it("validates tool type by id", async () => {
const mockResponse = {
data: { valid: true, errors: [] },
};
mockGet.mockResolvedValue(mockResponse);
const result = await validateToolType("type-1");
expect(result.valid).toBe(true);
expect(mockGet).toHaveBeenCalledWith("/tool-types/type-1/validate");
});
it("returns validation errors", async () => {
const mockResponse = {
data: { valid: false, errors: ["Invalid YAML"] },
};
mockGet.mockResolvedValue(mockResponse);
const result = await validateToolType("type-1");
expect(result.valid).toBe(false);
expect(result.errors).toContain("Invalid YAML");
});
});
describe("updateToolType", () => {
it("updates tool type with new fields", async () => {
const mockResponse = {
data: {
id: "type-1",
name: "updated-tool",
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11",
build_context: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
};
mockPut.mockResolvedValue(mockResponse);
const result = await updateToolType("type-1", {
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11",
});
expect(result.definition_type).toBe("dockerfile");
expect(mockPut).toHaveBeenCalledWith(
"/tool-types/type-1",
expect.objectContaining({
definition_type: "dockerfile",
})
);
});
});
describe("deleteToolType", () => {
it("deletes tool type", async () => {
mockDelete.mockResolvedValue({ data: undefined });
await deleteToolType("type-1");
expect(mockDelete).toHaveBeenCalledWith("/tool-types/type-1");
});
});
});
+25 -2
View File
@@ -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<string, string> | 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<string, string>;
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<string, string>;
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<void> => {
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;
};
+9 -12
View File
@@ -10,23 +10,20 @@ import { Icon } from "./icon";
import type { IconName } from "../utils/icons";
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
{ to: "/", label: "Dashboard", icon: "dashboard" },
{ to: "/sessions", label: "Sessions", icon: "terminal" },
{ to: "/", label: "Home", icon: "dashboard" },
{ 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" }
];
const SessionItem = ({ session }: { session: Session }) => {
const isRunning = session.status === "running";
return (
<a
href={session.url || "#"}
target="_blank"
rel="noopener noreferrer"
href={session.url ?? `/projects/${session.project_id}`}
target={session.url ? "_blank" : undefined}
rel={session.url ? "noopener noreferrer" : undefined}
className="nav-item session-item"
title={`${session.display_name} (${session.status})`}
>
@@ -86,7 +83,7 @@ export const AppShell = () => {
<div className="shell-body">
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => {
const isSessions = item.to === "/sessions";
const isHome = item.to === "/";
const activeCount = sessions.filter((s) => s.status === "running").length;
return (
<NavLink
@@ -97,7 +94,7 @@ export const AppShell = () => {
>
<Icon name={item.icon} size="sm" />
{item.label}
{isSessions && activeCount > 0 && (
{isHome && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span>
)}
</NavLink>
@@ -107,7 +104,7 @@ export const AppShell = () => {
{sessions.length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Sessions</div>
<div className="nav-section-title">Live sessions</div>
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
+57 -30
View File
@@ -1,54 +1,81 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DashboardPage } from "./dashboard";
import { HomePage } from "./dashboard";
const mockGet = vi.fn();
const mockDashboard = vi.fn();
const mockSessions = vi.fn();
const mockProjects = vi.fn();
const mockRepos = vi.fn();
vi.mock("../api/dashboard", () => ({
getDashboardSummary: (...args: unknown[]) => mockGet(...args)
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args)
}));
describe("DashboardPage", () => {
vi.mock("../api/sessions", () => ({
getUserSessions: (...args: unknown[]) => mockSessions(...args),
createInstance: vi.fn(),
startInstance: vi.fn(),
stopInstance: vi.fn(),
deleteInstance: vi.fn(),
recreateInstanceTunnel: vi.fn()
}));
vi.mock("../api/projects", () => ({
listProjects: (...args: unknown[]) => mockProjects(...args)
}));
vi.mock("../api/git_repositories", () => ({
listRepositories: (...args: unknown[]) => mockRepos(...args)
}));
vi.mock("../api/tool_types", () => ({
listToolTypes: vi.fn().mockResolvedValue([])
}));
describe("HomePage", () => {
beforeEach(() => {
mockGet.mockReset();
mockDashboard.mockReset();
mockSessions.mockReset();
mockProjects.mockReset();
mockRepos.mockReset();
});
it("shows loading then empty state when summary has no data", async () => {
mockGet.mockResolvedValue({
projects: 0,
repositories: 0,
sshKeys: 0,
recentActivity: []
});
it("shows overview sections", async () => {
mockDashboard.mockResolvedValue({ projects: 1, repositories: 2, sshKeys: 3, recentActivity: [] });
mockSessions.mockResolvedValue([]);
mockProjects.mockResolvedValue([]);
mockRepos.mockResolvedValue([]);
render(<DashboardPage />);
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>
);
expect(screen.getByText("Loading dashboard...")).toBeInTheDocument();
expect(screen.getByText("Loading overview...")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("No activity yet")).toBeInTheDocument();
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
expect(screen.getByText("Available projects")).toBeInTheDocument();
});
});
it("shows retry action when summary request fails", async () => {
mockGet.mockRejectedValueOnce(new Error("failed"));
mockGet.mockResolvedValueOnce({
projects: 2,
repositories: 5,
sshKeys: 1,
recentActivity: ["Created repo"]
});
it("shows retry action when home load fails", async () => {
mockDashboard.mockRejectedValueOnce(new Error("failed"));
mockSessions.mockRejectedValueOnce(new Error("failed"));
mockProjects.mockRejectedValueOnce(new Error("failed"));
render(<DashboardPage />);
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText("Dashboard is unavailable")).toBeInTheDocument();
expect(screen.getByText("Unable to load your workspace overview.")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(screen.getByText("2")).toBeInTheDocument();
});
fireEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]);
});
});
+299 -44
View File
@@ -1,83 +1,338 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, type Session as SessionApi } from "../api/sessions";
import { listProjects } from "../api/projects";
import { listRepositories, type GitRepository } from "../api/git_repositories";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { updateUserConfig } from "../api/settings";
import type { Project } from "../types";
import { Icon } from "../components/icon";
const CARDS = [
type HomeStatus = "loading" | "ready" | "error";
const summaryCards = [
{ label: "Open sessions", key: "openSessions" },
{ label: "Projects", key: "projects" },
{ label: "Repositories", key: "repositories" },
{ label: "SSH Keys", key: "sshKeys" }
] as const;
type DashboardStatus = "loading" | "ready" | "error";
type SessionView = SessionApi;
export const DashboardPage = () => {
const [status, setStatus] = useState<DashboardStatus>("loading");
export const HomePage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<HomeStatus>("loading");
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [sessions, setSessions] = useState<SessionView[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState("");
const [selectedRepo, setSelectedRepo] = useState("");
const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState("");
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
const [actionBusy, setActionBusy] = useState<string | null>(null);
const safeSessions = Array.isArray(sessions) ? sessions : [];
const loadSummary = useCallback(async () => {
const loadHome = useCallback(async () => {
setStatus("loading");
try {
const data = await getDashboardSummary();
setSummary(data);
const [dashboard, sessionData, projectData, toolTypeData] = await Promise.all([
getDashboardSummary(),
getUserSessions(),
listProjects(),
listToolTypes(),
]);
setSummary(dashboard);
setSessions(sessionData as SessionView[]);
setProjects(projectData);
setToolTypes(toolTypeData);
setStatus("ready");
} catch {
setSummary(null);
setStatus("error");
}
}, []);
useEffect(() => {
void loadSummary();
}, [loadSummary]);
void loadHome();
}, [loadHome]);
const cards = useMemo(() => CARDS, []);
const isEmpty =
status === "ready" &&
summary !== null &&
summary.projects === 0 &&
summary.repositories === 0 &&
summary.sshKeys === 0 &&
summary.recentActivity.length === 0;
useEffect(() => {
if (!selectedProject) {
setRepositories([]);
return;
}
const loadRepos = async () => {
try {
const data = await listRepositories(selectedProject);
setRepositories(data);
} catch {
setRepositories([]);
}
};
void loadRepos();
}, [selectedProject]);
const activeSessions = useMemo(
() => safeSessions.filter((session) => ["running", "building", "pending"].includes(session.status)),
[safeSessions]
);
const recentSessions = useMemo(
() => safeSessions.filter((session) => ["stopped", "error"].includes(session.status)).slice(0, 5),
[safeSessions]
);
const handleCreate = async (event: React.FormEvent) => {
event.preventDefault();
if (!selectedProject || !selectedRepo || !selectedToolType) return;
setSaveState("saving");
try {
const instance = await createInstance(selectedProject, selectedRepo, selectedToolType, displayName || undefined);
await startInstance(selectedProject, selectedRepo, instance.id);
await updateUserConfig({ last_session_id: instance.id });
setDisplayName("");
setSelectedProject("");
setSelectedRepo("");
setSelectedToolType("");
setSaveState("idle");
await loadHome();
} catch {
setSaveState("error");
}
};
const handleOpen = (session: SessionView) => {
if (session.url) {
window.open(session.url, "_blank", "noopener,noreferrer");
return;
}
if (session.tool_type_interfaces.includes("terminal")) {
navigate(`/instances/${session.id}/terminal`);
return;
}
navigate(`/projects/${session.project_id}`);
};
const handleStop = async (session: SessionView) => {
setActionBusy(session.id);
try {
await stopInstance(session.project_id, session.repository_id, session.id);
await loadHome();
} finally {
setActionBusy(null);
}
};
const handleDelete = async (session: SessionView) => {
setActionBusy(session.id);
try {
await deleteInstance(session.project_id, session.repository_id, session.id);
await loadHome();
} finally {
setActionBusy(null);
}
};
const handleRecreateTunnel = async (session: SessionView) => {
setActionBusy(session.id);
try {
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
await loadHome();
} finally {
setActionBusy(null);
}
};
return (
<section className="stack">
<h1>Dashboard</h1>
<p className="muted">Your workspace overview will appear here.</p>
<section className="stack home-page">
<header className="home-hero card">
<div className="stack-sm">
<p className="eyebrow">Workspace overview</p>
<h1>Home</h1>
<p className="muted">Open sessions, available projects, and the fastest path back into work.</p>
</div>
<div className="home-hero-actions">
<button className="primary-button" type="button" onClick={() => navigate("/projects")}>New Project</button>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Settings</button>
</div>
</header>
{status === "loading" && <p className="muted">Loading dashboard...</p>}
{status === "loading" && <p className="muted">Loading overview...</p>}
{status === "error" && (
<div className="card stack">
<p>Dashboard is unavailable</p>
<button className="secondary-button" onClick={() => void loadSummary()} type="button">
<p>Unable to load your workspace overview.</p>
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
<div className="card-grid">
{cards.map((card) => (
<article className="card" key={card.label}>
<p className="card-label">{card.label}</p>
<p className="card-value">{summary ? String(summary[card.key]) : "-"}</p>
</article>
))}
</div>
{status === "ready" && summary && (
<>
<div className="home-summary-grid">
{summaryCards.map((card) => (
<article className="card home-summary-card" key={card.label}>
<p className="card-label">{card.label}</p>
<p className="card-value">
{card.key === "openSessions"
? activeSessions.length
: card.key === "projects"
? summary.projects
: summary.repositories}
</p>
</article>
))}
</div>
{isEmpty && <p className="muted">No activity yet</p>}
<section className="card stack home-section">
<div className="page-header">
<div>
<p className="eyebrow">Open sessions</p>
<h2>{activeSessions.length}</h2>
</div>
</div>
{activeSessions.length === 0 ? (
<p className="muted">No active sessions right now.</p>
) : (
<div className="home-session-grid">
{activeSessions.map((session) => (
<article className="card session-card" key={session.id}>
<div className="stack-sm">
<div className="row row-tight">
<h3>{session.display_name}</h3>
<span className={`status-badge ${session.status}`}>{session.status}</span>
</div>
<p className="muted">{session.project_name} · {session.repository_name}</p>
<p className="muted">{session.tool_type_name}</p>
</div>
<div className="session-actions">
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
<Icon name="external" size="sm" />
Open
</button>
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
<Icon name="refresh" size="sm" />
Tunnel
</button>
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
<Icon name="stop" size="sm" />
Stop
</button>
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
<Icon name="delete" size="sm" />
Delete
</button>
</div>
</article>
))}
</div>
)}
</section>
<div className="quick-actions">
<button className="primary-button" type="button">
<Icon name="add" size="sm" />
New Project
</button>
<button className="secondary-button" type="button">
<Icon name="add" size="sm" />
Add Repository
</button>
</div>
<section className="card stack home-section">
<div className="page-header">
<div>
<p className="eyebrow">Available projects</p>
<h2>{projects.length}</h2>
</div>
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
</div>
{projects.length === 0 ? (
<p className="muted">No projects yet.</p>
) : (
<div className="home-project-grid">
{projects.map((project) => (
<article className="card project-card home-project-card" key={project.id}>
<div className="stack-sm">
<h3>{project.name}</h3>
{project.description && <p className="muted">{project.description}</p>}
</div>
<button className="ghost-button small" type="button" onClick={() => navigate(`/projects/${project.id}`)}>
Open Workspace
</button>
</article>
))}
</div>
)}
</section>
<section className="card stack home-section">
<div className="page-header">
<div>
<p className="eyebrow">Quick create</p>
<h2>Start a session</h2>
</div>
</div>
<form className="stack create-session-form" onSubmit={handleCreate}>
<div className="form-row">
<label className="form-field">
Project
<select value={selectedProject} onChange={(event) => { setSelectedProject(event.target.value); setSelectedRepo(""); }}>
<option value="">Select project...</option>
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
</select>
</label>
<label className="form-field">
Repository
<select value={selectedRepo} onChange={(event) => setSelectedRepo(event.target.value)} disabled={!selectedProject}>
<option value="">Select repository...</option>
{repositories.map((repo) => <option key={repo.id} value={repo.id}>{repo.name}</option>)}
</select>
</label>
<label className="form-field">
Tool type
<select value={selectedToolType} onChange={(event) => setSelectedToolType(event.target.value)}>
<option value="">Select tool...</option>
{toolTypes.map((tool) => <option key={tool.id} value={tool.id}>{tool.display_name}</option>)}
</select>
</label>
</div>
<label className="form-field">
Display name
<input type="text" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="My Development Environment" />
</label>
<div className="form-actions">
<button className="primary-button" type="submit" disabled={saveState === "saving"}>
{saveState === "saving" ? <><Icon name="loading" size="sm" /> Creating...</> : <><Icon name="add" size="sm" /> Create Session</>}
</button>
{saveState === "error" && <span className="error-text">Failed to create session</span>}
</div>
</form>
</section>
{recentSessions.length > 0 && (
<section className="card stack home-section">
<div className="page-header">
<div>
<p className="eyebrow">Recent sessions</p>
<h2>{recentSessions.length}</h2>
</div>
</div>
<div className="recent-sessions-list">
{recentSessions.map((session) => (
<article className="recent-session-item" key={session.id}>
<div className="recent-session-info">
<span className="recent-session-name">{session.display_name}</span>
<span className="muted">{session.project_name} · {session.tool_type_name}</span>
</div>
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button>
</article>
))}
</div>
</section>
)}
</>
)}
</section>
);
};
export { HomePage as DashboardPage };
+79 -79
View File
@@ -1,17 +1,33 @@
import { useCallback, useEffect, useState } from "react";
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
import { Icon } from "../components/icon";
type SettingsStatus = "loading" | "ready" | "error";
const TABS = [
{ label: "General", path: "general" },
{ label: "SSH Keys", path: "ssh-keys" },
{ label: "Tool Types", path: "tool-types" },
{ label: "Tool Configs", path: "tool-configs" },
] as const;
const THEME_OPTIONS = [
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
];
type SettingsOutletContext = {
config: UserConfig;
handleChange: (key: keyof UserConfigUpdate, value: string | null) => void;
handleSave: () => Promise<void>;
saveStatus: "idle" | "saving" | "saved" | "error";
};
export const SettingsPage = () => {
const location = useLocation();
const [status, setStatus] = useState<SettingsStatus>("loading");
const [config, setConfig] = useState<UserConfig>({
theme: "system",
@@ -50,21 +66,15 @@ export const SettingsPage = () => {
git_user_name: config.git_user_name,
git_user_email: config.git_user_email,
};
console.log("Sending update:", update);
const updated = await updateUserConfig(update);
console.log("Received response:", updated);
setConfig(updated);
setSaveStatus("saved");
// Apply theme immediately
const theme = updated.theme ?? "system";
if (theme === "system") {
if (updated.theme === "system") {
document.documentElement.removeAttribute("data-theme");
} else {
document.documentElement.setAttribute("data-theme", theme);
document.documentElement.setAttribute("data-theme", updated.theme);
}
setTimeout(() => setSaveStatus("idle"), 2000);
window.setTimeout(() => setSaveStatus("idle"), 2000);
} catch {
setSaveStatus("error");
}
@@ -86,81 +96,71 @@ export const SettingsPage = () => {
);
}
const parts = location.pathname.split("/").filter(Boolean);
const activePath = location.pathname.endsWith("/settings") ? "general" : (parts[parts.length - 1] ?? "general");
return (
<section className="stack">
<div className="page-header">
<h1>Settings</h1>
</div>
<section className="stack settings-page">
<header className="settings-header card stack-sm">
<div>
<p className="eyebrow">Configuration</p>
<h1>Settings</h1>
</div>
<p className="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
</header>
<div className="card stack">
<h2>Appearance</h2>
<label className="form-field">
Theme
<select
value={config.theme}
onChange={(e) => handleChange("theme", e.target.value)}
<nav className="settings-tabs" aria-label="Settings sections">
{TABS.map((tab) => (
<Link
key={tab.path}
className={`settings-tab ${activePath === tab.path ? "active" : ""}`}
to={tab.path === "general" ? "/settings" : `/settings/${tab.path}`}
>
{THEME_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
</div>
{tab.label}
</Link>
))}
</nav>
<div className="card stack">
<h2>Git Identity</h2>
<label className="form-field">
User Name
<input
type="text"
value={config.git_user_name ?? ""}
onChange={(e) => handleChange("git_user_name", e.target.value || null)}
placeholder="Your git commit name"
/>
</label>
<label className="form-field">
User Email
<input
type="email"
value={config.git_user_email ?? ""}
onChange={(e) => handleChange("git_user_email", e.target.value || null)}
placeholder="your.email@example.com"
/>
</label>
</div>
<div className="card stack">
<h2>Editor</h2>
<label className="form-field">
Default Editor
<input
type="text"
value={config.default_editor ?? ""}
onChange={(e) => handleChange("default_editor", e.target.value || null)}
placeholder="e.g., vscode, vim, cursor"
/>
</label>
</div>
<div className="settings-actions">
<button className="primary-button" onClick={() => void handleSave()} type="button">
{saveStatus === "saving" ? (
<>
<Icon name="loading" size="sm" />
Saving...
</>
) : (
<>
<Icon name="save" size="sm" />
Save Settings
</>
)}
</button>
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
<div className="settings-panel card">
<Outlet context={{ config, handleChange, handleSave, saveStatus }} />
</div>
</section>
);
};
export const GeneralSettingsTab = () => {
const { config, handleChange, handleSave, saveStatus } = useOutletContext<SettingsOutletContext>();
return (
<div className="stack">
<h2>General</h2>
<label className="form-field">
Theme
<select value={config.theme} onChange={(e) => handleChange("theme", e.target.value)}>
{THEME_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</label>
<label className="form-field">
Git user name
<input type="text" value={config.git_user_name ?? ""} onChange={(e) => handleChange("git_user_name", e.target.value || null)} placeholder="Your git commit name" />
</label>
<label className="form-field">
Git user email
<input type="email" value={config.git_user_email ?? ""} onChange={(e) => handleChange("git_user_email", e.target.value || null)} placeholder="your.email@example.com" />
</label>
<label className="form-field">
Default editor
<input type="text" value={config.default_editor ?? ""} onChange={(e) => handleChange("default_editor", e.target.value || null)} placeholder="e.g., vscode, vim, cursor" />
</label>
<div className="settings-actions">
<button className="primary-button" onClick={() => void handleSave()} type="button">
{saveStatus === "saving" ? <><Icon name="loading" size="sm" /> Saving...</> : <><Icon name="save" size="sm" /> Save Settings</>}
</button>
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
</div>
</div>
);
};
+11 -1
View File
@@ -1,8 +1,10 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
import { Icon } from "../components/icon";
export const SSHKeysPage = () => {
const navigate = useNavigate();
const [keys, setKeys] = useState<SSHKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -61,7 +63,15 @@ export const SSHKeysPage = () => {
return (
<section className="stack">
<h1>SSH Keys</h1>
<div className="page-header">
<div>
<p className="eyebrow">Settings</p>
<h1>SSH Keys</h1>
</div>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
Back to settings
</button>
</div>
{error && <div className="error">{error}</div>}
+9 -1
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Icon } from "../components/icon";
import { listToolTypes, type ToolType } from "../api/tool_types";
@@ -13,6 +14,7 @@ import {
type ConfigStatus = "loading" | "ready" | "error";
export const ToolConfigsPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<ConfigStatus>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [configs, setConfigs] = useState<ToolConfig[]>([]);
@@ -135,7 +137,13 @@ export const ToolConfigsPage = () => {
return (
<section className="stack">
<div className="page-header">
<h1>Tool Configurations</h1>
<div>
<p className="eyebrow">Settings</p>
<h1>Tool Configurations</h1>
</div>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
Back to settings
</button>
<p className="muted">
Manage environment variables and configuration files for your tools
</p>
+12 -4
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
createToolType,
@@ -15,6 +16,7 @@ type ToolTypesStatus = "loading" | "ready" | "error";
type DialogMode = "none" | "create" | "edit";
export const ToolTypesPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<ToolTypesStatus>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
@@ -67,7 +69,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);
@@ -165,12 +167,18 @@ export const ToolTypesPage = () => {
return (
<div className="container">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
<h1>Tool Types</h1>
<button onClick={openCreate}>
<div className="page-header" style={{ marginBottom: "1rem" }}>
<div>
<p className="eyebrow">Settings</p>
<h1>Tool Types</h1>
</div>
<div className="row">
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Back to settings</button>
<button onClick={openCreate}>
<Icon name="add" size="sm" />
Create Tool Type
</button>
</div>
</div>
{toolTypes.length === 0 ? (
+527
View File
@@ -0,0 +1,527 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ToolWorkshopPage } from "./tool-workshop";
import * as toolTypesApi from "../api/tool_types";
import * as toolConfigsApi from "../api/tool_configs";
import * as configFoldersApi from "../api/config_folders";
const mockToolTypes = [
{
id: "type-1",
name: "code-server",
display_name: "VS Code Server",
description: "VS Code in browser",
category: "editor",
interfaces: ["web"],
default_port: 8443,
definition_type: "compose",
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
dockerfile_template: null,
build_context: null,
readiness_probe: null,
required_variables: ["REPO_PATH"],
is_builtin: true,
created_by_id: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
{
id: "type-2",
name: "custom-tool",
display_name: "Custom Tool",
description: "My custom tool",
category: "utility",
interfaces: ["terminal"],
default_port: 8080,
definition_type: "dockerfile",
compose_template: null,
dockerfile_template: "FROM python:3.11",
build_context: null,
readiness_probe: {
command: "python --version",
timeout: 30,
interval: 2,
},
required_variables: [],
is_builtin: false,
created_by_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
const mockConfigs = [
{
id: "config-1",
tool_type_id: "type-1",
project_id: null,
key: "OPENAI_API_KEY",
value: "sk-test123",
config_type: "env",
file_path: null,
port_override: null,
start_command: null,
working_directory: null,
environment_variables: {},
volumes: [],
},
{
id: "config-2",
tool_type_id: "type-2",
project_id: null,
key: "advanced-config",
value: "test-value",
config_type: "env",
file_path: null,
port_override: 9090,
start_command: "python app.py",
working_directory: "/app",
environment_variables: { DEBUG: "true" },
volumes: [{ source: "data", target: "/data", type: "bind" }],
},
];
const mockFolders = [
{
id: "folder-1",
user_id: "user-1",
name: "my-dotfiles",
description: "My personal config files",
mount_path: "/home/user",
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
project_overrides: {},
is_active: true,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
{
id: "folder-2",
user_id: "user-1",
name: "project-configs",
description: "Project specific configs",
mount_path: "/workspace",
files: { ".env": "API_URL=http://localhost:8080" },
project_overrides: {
"proj-1": {
mount_path: "/app",
files: { ".env": "API_URL=http://prod.api" },
},
},
is_active: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("ToolWorkshopPage", () => {
it("renders loading state initially", () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockImplementation(() => new Promise(() => {}));
vi.spyOn(toolConfigsApi, "listToolConfigs").mockImplementation(() => new Promise(() => {}));
vi.spyOn(configFoldersApi, "listConfigFolders").mockImplementation(() => new Promise(() => {}));
render(<ToolWorkshopPage />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("renders tool types tab by default", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
});
it("switches to configs tab", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
expect(screen.getByText("advanced-config")).toBeInTheDocument();
});
it("switches to folders tab", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
expect(screen.getByText("project-configs")).toBeInTheDocument();
});
it("opens tool type creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
expect(screen.getByLabelText("Display Name *")).toBeInTheDocument();
});
it("creates tool type with compose definition", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText("Name *"), {
target: { value: "new-tool" },
});
fireEvent.change(screen.getByLabelText("Display Name *"), {
target: { value: "New Tool" },
});
fireEvent.change(screen.getByLabelText("Default Port *"), {
target: { value: "8080" },
});
fireEvent.change(screen.getByLabelText(/compose template/i), {
target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "new-tool",
display_name: "New Tool",
definition_type: "compose",
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: nginx",
})
);
});
});
it("creates tool type with dockerfile definition", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText("Name *"), {
target: { value: "docker-tool" },
});
fireEvent.change(screen.getByLabelText("Display Name *"), {
target: { value: "Docker Tool" },
});
fireEvent.change(screen.getByLabelText("Default Port *"), {
target: { value: "3000" },
});
// Switch to dockerfile
fireEvent.change(screen.getByLabelText("Definition Type"), {
target: { value: "dockerfile" },
});
fireEvent.change(screen.getByLabelText("Dockerfile *"), {
target: { value: "FROM python:3.11\\nRUN pip install flask" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "docker-tool",
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11\\nRUN pip install flask",
})
);
});
});
it("shows readiness probe fields", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
expect(screen.getByText(/readiness probe command/i)).toBeInTheDocument();
expect(screen.getByText(/timeout/i)).toBeInTheDocument();
expect(screen.getByText(/interval/i)).toBeInTheDocument();
});
it("opens config creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
expect(screen.getByLabelText(/key/i)).toBeInTheDocument();
expect(screen.getByLabelText(/value/i)).toBeInTheDocument();
});
it("creates config with advanced fields", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1] as unknown as toolConfigsApi.ToolConfig);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
fireEvent.change(screen.getByLabelText(/key/i), {
target: { value: "MY_CONFIG" },
});
fireEvent.change(screen.getByLabelText(/value/i), {
target: { value: "my-value" },
});
fireEvent.change(screen.getByLabelText(/port override/i), {
target: { value: "9090" },
});
fireEvent.change(screen.getByLabelText(/start command/i), {
target: { value: "python app.py" },
});
fireEvent.click(screen.getByRole("button", { name: /add$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
key: "MY_CONFIG",
value: "my-value",
port_override: 9090,
start_command: "python app.py",
})
);
});
expect(configsListMock).toHaveBeenCalledTimes(2);
});
it("opens folder creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
expect(screen.getByLabelText("Mount Path *")).toBeInTheDocument();
});
it("creates config folder successfully", async () => {
const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0] as unknown as configFoldersApi.ConfigFolder);
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
fireEvent.change(screen.getByLabelText("Name *"), {
target: { value: "new-folder" },
});
fireEvent.change(screen.getByLabelText("Mount Path *"), {
target: { value: "/home/dev" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "new-folder",
mount_path: "/home/dev",
})
);
});
expect(foldersListMock).toHaveBeenCalledTimes(2);
});
it("shows folder active/inactive status", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
// Check that active folder shows Active badge
expect(screen.getByText("Active")).toBeInTheDocument();
});
it("handles error state gracefully", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockRejectedValue(new Error("Network error"));
vi.spyOn(toolConfigsApi, "listToolConfigs").mockRejectedValue(new Error("Network error"));
vi.spyOn(configFoldersApi, "listConfigFolders").mockRejectedValue(new Error("Network error"));
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
it("retries loading after error", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes")
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs")
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders")
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
expect(listMock).toHaveBeenCalledTimes(2);
});
it("deletes tool type successfully", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
});
// Find and click delete button for custom tool (not built-in)
const customToolCard = screen.getByText("Custom Tool").closest(".card") ||
screen.getByText("Custom Tool").parentElement;
if (customToolCard) {
const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i });
if (deleteButton) {
vi.spyOn(window, "confirm").mockReturnValue(true);
fireEvent.click(deleteButton);
await waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith("type-2");
});
expect(listMock).toHaveBeenCalledTimes(2);
}
}
});
});
File diff suppressed because it is too large Load Diff
+18 -10
View File
@@ -2,18 +2,18 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { AppShell } from "./components/app-shell";
import { ProtectedRoute } from "./components/protected-route";
import { DashboardPage } from "./pages/dashboard";
import { HomePage } from "./pages/dashboard";
import { LoginRedirectPage, NotFoundPage } from "./pages/placeholder";
import { SessionsPage } from "./pages/sessions";
import { ProfilePage } from "./pages/profile";
import { ProjectsPage } from "./pages/projects";
import { GitRepositoriesPage } from "./pages/git-repositories";
import { GitHistoryPage } from "./pages/git-history";
import { ProjectSettingsPage } from "./pages/project-settings";
import { RepoWorkspace } from "./pages/repo-workspace";
import { SSHKeysPage } from "./pages/ssh-keys";
import { SettingsPage } from "./pages/settings";
import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
import { TerminalPage } from "./pages/terminal";
import { ToolWorkshopPage } from "./pages/tool-workshop";
import { SSHKeysPage } from "./pages/ssh-keys";
import { ToolConfigsPage } from "./pages/tool-configs";
import { ToolTypesPage } from "./pages/tool-types";
@@ -21,6 +21,10 @@ export const AppRouter = () => {
return (
<Routes>
<Route path="/login" element={<LoginRedirectPage />} />
<Route path="/sessions" element={<Navigate to="/" replace />} />
<Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} />
<Route path="/tool-types" element={<Navigate to="/settings/tool-types" replace />} />
<Route path="/tool-configs" element={<Navigate to="/settings/tool-configs" replace />} />
<Route
path="/"
element={
@@ -29,18 +33,22 @@ export const AppRouter = () => {
</ProtectedRoute>
}
>
<Route index element={<DashboardPage />} />
<Route path="sessions" element={<SessionsPage />} />
<Route index element={<HomePage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:projectId" element={<RepoWorkspace />} />
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="tool-types" element={<ToolTypesPage />} />
<Route path="tool-configs" element={<ToolConfigsPage />} />
<Route path="settings" element={<SettingsPage />}>
<Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<GeneralSettingsTab />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="tool-types" element={<ToolTypesPage />} />
<Route path="tool-configs" element={<ToolConfigsPage />} />
<Route path="*" element={<Navigate to="general" replace />} />
</Route>
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
</Route>
<Route path="/404" element={<NotFoundPage />} />
+148 -10
View File
@@ -1,6 +1,6 @@
:root {
color-scheme: light;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
--bg: #f4f1ea;
--panel: #fffef9;
--ink: #1d1d1b;
@@ -8,6 +8,17 @@
--brand: #275d4b;
--brand-strong: #154236;
--border: #d8d0c5;
--primary: #275d4b;
--primary-fg: #fffef9;
--color-primary: #275d4b;
--success: #2f8f62;
--success-light: rgba(47, 143, 98, 0.14);
--warning: #c08a1e;
--warning-light: rgba(192, 138, 30, 0.14);
--danger: #b94a3c;
--danger-light: rgba(185, 74, 60, 0.14);
--info: #4f7fb8;
--info-light: rgba(79, 127, 184, 0.14);
/* Spacing Scale (4px base) */
--space-1: 0.25rem;
@@ -36,13 +47,16 @@
[data-theme="dark"] {
color-scheme: dark;
--bg: #1a1a18;
--panel: #252522;
--ink: #e8e6e1;
--muted: #a39e96;
--brand: #4a9e7f;
--brand-strong: #3d8a6e;
--border: #3d3d38;
--bg: #171613;
--panel: #22201d;
--ink: #ece7df;
--muted: #a59d92;
--brand: #5fa889;
--brand-strong: #4d9175;
--border: #39342d;
--primary: #5fa889;
--primary-fg: #171613;
--color-primary: #5fa889;
--success: #22c55e;
--success-light: rgba(34, 197, 94, 0.15);
--warning: #f59e0b;
@@ -84,12 +98,12 @@ a {
align-items: center;
padding: 0.85rem 1.25rem;
border-bottom: 1px solid var(--border);
background: rgba(255, 255, 255, 0.85);
background: color-mix(in srgb, var(--panel) 88%, transparent);
backdrop-filter: blur(7px);
}
[data-theme="dark"] .shell-header {
background: rgba(37, 37, 34, 0.85);
background: color-mix(in srgb, var(--panel) 88%, transparent);
}
.brand {
@@ -115,6 +129,7 @@ a {
display: flex;
flex-direction: column;
gap: 0.4rem;
background: color-mix(in srgb, var(--panel) 65%, transparent);
}
.nav-item {
@@ -133,11 +148,134 @@ a {
color: #f7fff7;
}
.nav-section-title {
margin-top: 0.5rem;
padding: 0.25rem 0.75rem;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.nav-divider {
height: 1px;
background: var(--border);
margin: 0.5rem 0;
}
.shell-content {
padding: 1.25rem;
overflow-x: hidden;
}
.eyebrow {
margin: 0;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.home-page,
.settings-page {
max-width: 1240px;
}
.home-hero {
display: flex;
justify-content: space-between;
gap: var(--space-4);
align-items: flex-start;
}
.home-hero-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.home-summary-grid,
.home-project-grid,
.home-session-grid {
display: grid;
gap: var(--space-4);
}
.home-summary-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.home-project-grid,
.home-session-grid {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.home-section h2,
.settings-header h1,
.settings-panel h2 {
margin: 0;
}
.home-section h3,
.home-section p {
margin: 0;
}
.row-tight {
gap: var(--space-2);
}
.settings-tabs {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.settings-tab {
padding: 0.6rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--muted);
background: var(--panel);
}
.settings-tab.active {
background: var(--brand);
color: white;
border-color: transparent;
}
.settings-panel {
padding: 1.5rem;
}
.settings-actions,
.form-actions {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.error-text {
color: var(--danger);
}
.success-text {
color: var(--success);
}
.small {
padding: 0.42rem 0.7rem;
min-height: 38px;
}
.session-card,
.project-card,
.recent-session-item {
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
}
/* Responsive Shell */
@media (max-width: 767px) {
.shell-body {