merge: align dev branch with main
This commit is contained in:
+2
-3
@@ -27,7 +27,6 @@ WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
git \
|
||||
openssh-client \
|
||||
netcat-openbsd \
|
||||
ca-certificates \
|
||||
curl \
|
||||
@@ -50,8 +49,8 @@ ENV PATH=/root/.local/bin:$PATH
|
||||
# Copy application code
|
||||
COPY --chown=appuser:appgroup . .
|
||||
|
||||
# Create directories for repo, instance, and workspace storage
|
||||
RUN mkdir -p /data/repos /data/instances /data/working-copies && chown -R appuser:appgroup /data
|
||||
# Create directories for repo and instance storage
|
||||
RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data
|
||||
|
||||
# Copy wait-for-db script
|
||||
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
|
||||
|
||||
@@ -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")
|
||||
@@ -1,6 +1,4 @@
|
||||
from src.api.auth import router as auth_router
|
||||
from src.api.events import router as events_router
|
||||
from src.api.notifications import router as notifications_router
|
||||
from src.api.users import router as users_router
|
||||
|
||||
__all__ = ["auth_router", "events_router", "notifications_router", "users_router"]
|
||||
__all__ = ["auth_router", "users_router"]
|
||||
|
||||
+10
-10
@@ -48,7 +48,7 @@ async def login(next: str = "/") -> RedirectResponse:
|
||||
redirect_uri=redirect_uri,
|
||||
state=state,
|
||||
)
|
||||
logger.debug("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
|
||||
logger.info("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
|
||||
response = RedirectResponse(location)
|
||||
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
|
||||
response.set_cookie("auth_next", next, httponly=True, samesite="lax")
|
||||
@@ -63,7 +63,7 @@ async def callback(
|
||||
auth_next: str | None = Cookie(default="/"),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> RedirectResponse:
|
||||
logger.debug("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
|
||||
logger.info("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
|
||||
|
||||
if auth_state is None or auth_state != state:
|
||||
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
|
||||
@@ -71,7 +71,7 @@ async def callback(
|
||||
|
||||
settings = Settings()
|
||||
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
||||
logger.debug("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
|
||||
logger.info("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
@@ -92,7 +92,7 @@ async def callback(
|
||||
access_token=token_payload["access_token"],
|
||||
client=client,
|
||||
)
|
||||
logger.debug("User info fetched successfully")
|
||||
logger.info("User info fetched successfully")
|
||||
except Exception as exc:
|
||||
logger.error("User info fetch failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info")
|
||||
@@ -100,19 +100,19 @@ async def callback(
|
||||
authentik_id = str(user_info.get("sub", ""))
|
||||
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
|
||||
name = str(user_info.get("name", email))
|
||||
logger.debug("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
|
||||
logger.info("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
|
||||
|
||||
try:
|
||||
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
|
||||
if user is None:
|
||||
logger.debug("Creating new user: authentik_id=%s", authentik_id)
|
||||
logger.info("Creating new user: authentik_id=%s", authentik_id)
|
||||
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
logger.info("New user created: id=%s", user.id)
|
||||
else:
|
||||
logger.debug("Existing user found: id=%s, updating info", user.id)
|
||||
logger.info("Existing user found: id=%s, updating info", user.id)
|
||||
user.email = email
|
||||
user.name = name
|
||||
await session.commit()
|
||||
@@ -165,20 +165,20 @@ async def me(
|
||||
session_cookie: str | None = Cookie(default=None, alias="session"),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, Any]:
|
||||
logger.debug("Auth /me called, cookie present: %s", bool(session_cookie))
|
||||
logger.info("Auth /me called, cookie present: %s", bool(session_cookie))
|
||||
|
||||
if not session_cookie:
|
||||
logger.warning("Auth /me: missing session cookie")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
||||
|
||||
settings = Settings()
|
||||
logger.debug("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
|
||||
logger.info("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
|
||||
settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
|
||||
|
||||
try:
|
||||
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||
user_id = payload["user_id"]
|
||||
logger.debug("Auth /me: decoded session for user_id=%s", user_id)
|
||||
logger.info("Auth /me: decoded session for user_id=%s", user_id)
|
||||
except ValueError as exc:
|
||||
logger.warning("Auth /me: invalid session: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Config folder API endpoints."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.config_folder import ConfigFolder
|
||||
from src.schemas.config_folder import (
|
||||
ConfigFolderCreate,
|
||||
ConfigFolderUpdate,
|
||||
ConfigFolderResponse,
|
||||
ProjectOverrideCreate,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
|
||||
|
||||
|
||||
@router.get("", summary="List config folders", description="Get all config folders for the current user.")
|
||||
async def list_config_folders(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""List config folders for the current user."""
|
||||
query = select(ConfigFolder).where(ConfigFolder.user_id == user_id)
|
||||
result = await session.execute(query)
|
||||
folders = result.scalars().all()
|
||||
|
||||
return {
|
||||
"folders": [
|
||||
{
|
||||
"id": str(f.id),
|
||||
"user_id": str(f.user_id),
|
||||
"name": f.name,
|
||||
"description": f.description,
|
||||
"mount_path": f.mount_path,
|
||||
"files": f.files,
|
||||
"project_overrides": f.project_overrides,
|
||||
"is_active": f.is_active,
|
||||
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
for f in folders
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("", summary="Create config folder", description="Create a new config folder.", status_code=status.HTTP_201_CREATED)
|
||||
async def create_config_folder(
|
||||
data: ConfigFolderCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Create a config folder."""
|
||||
# Check for duplicate name
|
||||
existing = await session.scalar(
|
||||
select(ConfigFolder).where(
|
||||
ConfigFolder.user_id == user_id,
|
||||
ConfigFolder.name == data.name,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"config folder with name '{data.name}' already exists"
|
||||
)
|
||||
|
||||
folder = ConfigFolder(
|
||||
user_id=user_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
mount_path=data.mount_path,
|
||||
files=data.files,
|
||||
)
|
||||
session.add(folder)
|
||||
await session.commit()
|
||||
await session.refresh(folder)
|
||||
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"user_id": str(folder.user_id),
|
||||
"name": folder.name,
|
||||
"description": folder.description,
|
||||
"mount_path": folder.mount_path,
|
||||
"files": folder.files,
|
||||
"project_overrides": folder.project_overrides,
|
||||
"is_active": folder.is_active,
|
||||
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
||||
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{folder_id}", summary="Update config folder", description="Update an existing config folder.")
|
||||
async def update_config_folder(
|
||||
folder_id: uuid.UUID,
|
||||
data: ConfigFolderUpdate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Update a config folder."""
|
||||
folder = await session.get(ConfigFolder, folder_id)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||
|
||||
if data.name is not None:
|
||||
folder.name = data.name
|
||||
if data.description is not None:
|
||||
folder.description = data.description
|
||||
if data.mount_path is not None:
|
||||
folder.mount_path = data.mount_path
|
||||
if data.files is not None:
|
||||
folder.files = data.files
|
||||
if data.is_active is not None:
|
||||
folder.is_active = data.is_active
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(folder)
|
||||
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"user_id": str(folder.user_id),
|
||||
"name": folder.name,
|
||||
"description": folder.description,
|
||||
"mount_path": folder.mount_path,
|
||||
"files": folder.files,
|
||||
"project_overrides": folder.project_overrides,
|
||||
"is_active": folder.is_active,
|
||||
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
||||
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{folder_id}", summary="Delete config folder", description="Delete a config folder.", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_config_folder(
|
||||
folder_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""Delete a config folder."""
|
||||
folder = await session.get(ConfigFolder, folder_id)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||
|
||||
await session.delete(folder)
|
||||
await session.commit()
|
||||
|
||||
|
||||
class ProjectOverrideWithId(ProjectOverrideCreate):
|
||||
project_id: uuid.UUID = Field(description="Project ID for the override")
|
||||
|
||||
|
||||
@router.get("/{folder_id}", summary="Get config folder by ID", description="Get a single config folder by its ID.")
|
||||
async def get_config_folder(
|
||||
folder_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get a config folder by ID."""
|
||||
folder = await session.get(ConfigFolder, folder_id)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"user_id": str(folder.user_id),
|
||||
"name": folder.name,
|
||||
"description": folder.description,
|
||||
"mount_path": folder.mount_path,
|
||||
"files": folder.files,
|
||||
"project_overrides": folder.project_overrides,
|
||||
"is_active": folder.is_active,
|
||||
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
||||
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{folder_id}/overrides", summary="Add project override", description="Add a project override to a config folder.")
|
||||
async def add_project_override(
|
||||
folder_id: uuid.UUID,
|
||||
data: ProjectOverrideWithId,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Add a project override to a config folder."""
|
||||
folder = await session.get(ConfigFolder, folder_id)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||
|
||||
# Initialize project_overrides if None
|
||||
if folder.project_overrides is None:
|
||||
folder.project_overrides = {}
|
||||
|
||||
# Add/update override
|
||||
override_data = {}
|
||||
if data.mount_path is not None:
|
||||
override_data["mount_path"] = data.mount_path
|
||||
if data.files is not None:
|
||||
override_data["files"] = data.files
|
||||
|
||||
# Use a copy to trigger SQLAlchemy change detection on JSONB
|
||||
current_overrides = dict(folder.project_overrides or {})
|
||||
current_overrides[str(data.project_id)] = override_data
|
||||
folder.project_overrides = current_overrides
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(folder)
|
||||
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"project_overrides": folder.project_overrides,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{folder_id}/overrides/{project_id}", summary="Update project override", description="Update a project override.")
|
||||
async def update_project_override(
|
||||
folder_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
data: ProjectOverrideCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Update a project override."""
|
||||
folder = await session.get(ConfigFolder, folder_id)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||
|
||||
# Initialize project_overrides if None
|
||||
if folder.project_overrides is None:
|
||||
folder.project_overrides = {}
|
||||
|
||||
# Update override
|
||||
current_overrides = dict(folder.project_overrides or {})
|
||||
override_data = current_overrides.get(str(project_id), {})
|
||||
if data.mount_path is not None:
|
||||
override_data["mount_path"] = data.mount_path
|
||||
if data.files is not None:
|
||||
override_data["files"] = data.files
|
||||
|
||||
current_overrides[str(project_id)] = override_data
|
||||
folder.project_overrides = current_overrides
|
||||
|
||||
# Mark the field as modified to ensure SQLAlchemy detects the change
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
flag_modified(folder, "project_overrides")
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(folder)
|
||||
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"project_overrides": folder.project_overrides,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{folder_id}/overrides/{project_id}", summary="Remove project override", description="Remove a project override.")
|
||||
async def remove_project_override(
|
||||
folder_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""Remove a project override."""
|
||||
folder = await session.get(ConfigFolder, folder_id)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||
|
||||
# Remove override if exists
|
||||
current_overrides = dict(folder.project_overrides or {})
|
||||
if str(project_id) in current_overrides:
|
||||
del current_overrides[str(project_id)]
|
||||
folder.project_overrides = current_overrides
|
||||
await session.commit()
|
||||
await session.refresh(folder)
|
||||
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"project_overrides": folder.project_overrides or {},
|
||||
}
|
||||
+241
-950
File diff suppressed because it is too large
Load Diff
+144
-1477
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,18 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import APIRouter, status
|
||||
from sqlalchemy import text
|
||||
|
||||
from src.config import Settings
|
||||
from src.database import SessionLocal
|
||||
from src.schemas.health import (
|
||||
DatabaseHealth,
|
||||
DatabaseHealthResponse,
|
||||
DiskHealth,
|
||||
HealthChecks,
|
||||
HealthResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -16,45 +23,6 @@ router = APIRouter()
|
||||
_start_time = time.time()
|
||||
|
||||
|
||||
class DatabaseHealth(BaseModel):
|
||||
"""Database health check result."""
|
||||
|
||||
status: str = Field(description="Database health status", examples=["healthy"])
|
||||
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
|
||||
|
||||
|
||||
class DiskHealth(BaseModel):
|
||||
"""Disk space health check result."""
|
||||
|
||||
status: str = Field(description="Disk health status", examples=["healthy"])
|
||||
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
|
||||
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
|
||||
|
||||
|
||||
class HealthChecks(BaseModel):
|
||||
"""Individual health checks."""
|
||||
|
||||
database: DatabaseHealth | None = None
|
||||
disk: DiskHealth | None = None
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Overall health check response."""
|
||||
|
||||
status: str = Field(description="Overall health status", examples=["healthy"])
|
||||
timestamp: str = Field(description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"])
|
||||
version: str = Field(description="API version", examples=["0.1.0"])
|
||||
checks: HealthChecks = Field(description="Individual health checks")
|
||||
uptime_seconds: float = Field(description="Server uptime in seconds", examples=[3600.0])
|
||||
|
||||
|
||||
class DatabaseHealthResponse(BaseModel):
|
||||
"""Database-specific health check response."""
|
||||
|
||||
status: str = Field(description="Database health status", examples=["healthy"])
|
||||
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/health",
|
||||
response_model=HealthResponse,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
|
||||
+33
-113
@@ -3,47 +3,24 @@ import shutil
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import (
|
||||
_get_owned_project,
|
||||
_get_user,
|
||||
get_current_user_id,
|
||||
get_db_session,
|
||||
)
|
||||
from src.auth.dependencies import get_current_user, get_db_session, get_owned_project
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.user import User
|
||||
from src.schemas.project import (
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
ProjectResponse,
|
||||
SetDefaultSSHKeyRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: str | None
|
||||
owner_id: uuid.UUID
|
||||
default_ssh_key_id: uuid.UUID | None
|
||||
|
||||
|
||||
class SetDefaultSSHKeyRequest(BaseModel):
|
||||
ssh_key_id: uuid.UUID
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
@@ -54,7 +31,7 @@ class SetDefaultSSHKeyRequest(BaseModel):
|
||||
)
|
||||
async def create_project(
|
||||
data: ProjectCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Project:
|
||||
"""Create a new project.
|
||||
@@ -67,7 +44,6 @@ async def create_project(
|
||||
Returns:
|
||||
The newly created project.
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
project = Project(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
@@ -82,77 +58,25 @@ async def create_project(
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=list[ProjectResponse],
|
||||
summary="List all projects",
|
||||
description="Retrieve all projects owned by the authenticated user with repositories and workspaces.",
|
||||
description="Retrieve all projects owned by the authenticated user.",
|
||||
)
|
||||
async def list_projects(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[dict]:
|
||||
) -> list[Project]:
|
||||
"""List all projects for the authenticated user.
|
||||
|
||||
Returns projects with nested repositories and workspaces for inline display.
|
||||
Args:
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
List of projects owned by the user.
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
result = await session.execute(
|
||||
select(Project)
|
||||
.where(Project.owner_id == user.id)
|
||||
.order_by(Project.created_at.desc())
|
||||
)
|
||||
projects = result.scalars().all()
|
||||
|
||||
from src.models.workspace import Workspace
|
||||
|
||||
enriched = []
|
||||
for project in projects:
|
||||
repos_result = await session.execute(
|
||||
select(GitRepository).where(GitRepository.project_id == project.id)
|
||||
)
|
||||
repositories = []
|
||||
for repo in repos_result.scalars().all():
|
||||
ws_result = await session.execute(
|
||||
select(Workspace).where(Workspace.repo_id == repo.id)
|
||||
)
|
||||
workspaces = []
|
||||
for ws in ws_result.scalars().all():
|
||||
# Count instances
|
||||
inst_result = await session.execute(
|
||||
select(func.count()).where(ToolInstance.workspace_id == ws.id)
|
||||
)
|
||||
instance_count = inst_result.scalar() or 0
|
||||
workspaces.append(
|
||||
{
|
||||
"id": str(ws.id),
|
||||
"name": ws.name,
|
||||
"branch": ws.branch,
|
||||
"status": ws.status,
|
||||
"instance_count": instance_count,
|
||||
}
|
||||
)
|
||||
|
||||
repositories.append(
|
||||
{
|
||||
"id": str(repo.id),
|
||||
"name": repo.name,
|
||||
"remote_url": repo.remote_url,
|
||||
"workspaces": workspaces,
|
||||
}
|
||||
)
|
||||
|
||||
enriched.append(
|
||||
{
|
||||
"id": str(project.id),
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"owner_id": str(project.owner_id),
|
||||
"repositories": repositories,
|
||||
"created_at": project.created_at.isoformat()
|
||||
if project.created_at
|
||||
else None,
|
||||
}
|
||||
)
|
||||
|
||||
return enriched
|
||||
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -163,7 +87,8 @@ async def list_projects(
|
||||
)
|
||||
async def get_project(
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
project: Project = Depends(get_owned_project),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Project:
|
||||
"""Get a specific project by ID.
|
||||
@@ -176,8 +101,8 @@ async def get_project(
|
||||
Returns:
|
||||
The requested project.
|
||||
"""
|
||||
await _get_user(session, user_id)
|
||||
return await _get_owned_project(project_id, user_id, session)
|
||||
return project
|
||||
|
||||
|
||||
|
||||
@router.patch(
|
||||
@@ -189,7 +114,8 @@ async def get_project(
|
||||
async def update_project(
|
||||
project_id: uuid.UUID,
|
||||
data: ProjectUpdate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
project: Project = Depends(get_owned_project),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Project:
|
||||
"""Update a project.
|
||||
@@ -203,8 +129,6 @@ async def update_project(
|
||||
Returns:
|
||||
The updated project.
|
||||
"""
|
||||
await _get_user(session, user_id)
|
||||
project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
if data.name is not None:
|
||||
project.name = data.name
|
||||
@@ -224,7 +148,8 @@ async def update_project(
|
||||
)
|
||||
async def delete_project(
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
project: Project = Depends(get_owned_project),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
"""Delete a project and all its repositories.
|
||||
@@ -237,13 +162,9 @@ async def delete_project(
|
||||
Returns:
|
||||
Empty response with 204 status code.
|
||||
"""
|
||||
await _get_user(session, user_id)
|
||||
project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
# Delete repositories from disk and database
|
||||
result = await session.execute(
|
||||
select(GitRepository).where(GitRepository.project_id == project_id)
|
||||
)
|
||||
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
|
||||
repositories = result.scalars().all()
|
||||
for repo in repositories:
|
||||
if os.path.exists(repo.path):
|
||||
@@ -264,7 +185,8 @@ async def delete_project(
|
||||
async def set_default_ssh_key(
|
||||
project_id: uuid.UUID,
|
||||
data: SetDefaultSSHKeyRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
project: Project = Depends(get_owned_project),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Project:
|
||||
"""Set the default SSH key for a project.
|
||||
@@ -278,8 +200,6 @@ async def set_default_ssh_key(
|
||||
Returns:
|
||||
The updated project.
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
ssh_key = await session.get(SSHKey, data.ssh_key_id)
|
||||
if ssh_key is None or ssh_key.user_id != user.id:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import base64
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
@@ -6,17 +5,19 @@ from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||
from src.auth.dependencies import get_current_user, get_db_session
|
||||
from src.config import Settings
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
from src.schemas.ssh_key import SSHKeyCreate, SSHKeyResponse
|
||||
|
||||
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
||||
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
"""Generate a valid Fernet key from the session secret."""
|
||||
import base64
|
||||
@@ -53,36 +54,6 @@ def generate_ssh_key_pair() -> tuple[str, str]:
|
||||
return private_bytes.decode("utf-8"), public_bytes.decode("utf-8")
|
||||
|
||||
|
||||
class SSHKeyCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class SSHKeyResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
public_key: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SignPayloadRequest(BaseModel):
|
||||
payload: str
|
||||
|
||||
|
||||
class SignatureResponse(BaseModel):
|
||||
signature: str
|
||||
|
||||
|
||||
class VerifySignatureRequest(BaseModel):
|
||||
payload: str
|
||||
signature: str
|
||||
|
||||
|
||||
class VerifySignatureResponse(BaseModel):
|
||||
valid: bool
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=SSHKeyResponse,
|
||||
@@ -92,7 +63,7 @@ class VerifySignatureResponse(BaseModel):
|
||||
)
|
||||
async def create_ssh_key(
|
||||
data: SSHKeyCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> SSHKey:
|
||||
"""Create a new SSH key pair.
|
||||
@@ -105,7 +76,6 @@ async def create_ssh_key(
|
||||
Returns:
|
||||
The newly created SSH key with public key exposed.
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
private_key, public_key = generate_ssh_key_pair()
|
||||
|
||||
fernet = _get_fernet()
|
||||
@@ -130,7 +100,7 @@ async def create_ssh_key(
|
||||
description="List all SSH keys for the authenticated user.",
|
||||
)
|
||||
async def list_ssh_keys(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[SSHKey]:
|
||||
"""List all SSH keys for the authenticated user.
|
||||
@@ -142,7 +112,6 @@ async def list_ssh_keys(
|
||||
Returns:
|
||||
List of SSH keys owned by the user.
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -155,7 +124,7 @@ async def list_ssh_keys(
|
||||
)
|
||||
async def delete_ssh_key(
|
||||
key_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""Delete an SSH key.
|
||||
@@ -168,87 +137,9 @@ async def delete_ssh_key(
|
||||
Returns:
|
||||
None with 204 status code.
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
ssh_key = await session.get(SSHKey, key_id)
|
||||
if ssh_key is None or ssh_key.user_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||
|
||||
await session.delete(ssh_key)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{key_id}/sign",
|
||||
response_model=SignatureResponse,
|
||||
summary="Sign payload",
|
||||
description="Sign a payload using the SSH private key.",
|
||||
)
|
||||
async def sign_payload(
|
||||
key_id: uuid.UUID,
|
||||
data: SignPayloadRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> SignatureResponse:
|
||||
"""Sign a payload with an SSH key.
|
||||
|
||||
Args:
|
||||
key_id: UUID of the SSH key to use for signing.
|
||||
data: Sign request containing the payload string.
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Base64-encoded Ed25519 signature.
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
ssh_key = await session.get(SSHKey, key_id)
|
||||
if ssh_key is None or ssh_key.user_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||
|
||||
fernet = _get_fernet()
|
||||
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||
|
||||
private_key = serialization.load_ssh_private_key(
|
||||
private_key_pem.encode(), password=None
|
||||
)
|
||||
|
||||
signature = private_key.sign(data.payload.encode())
|
||||
return SignatureResponse(signature=base64.b64encode(signature).decode())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{key_id}/verify",
|
||||
response_model=VerifySignatureResponse,
|
||||
summary="Verify signature",
|
||||
description="Verify a signature against a payload using the SSH public key.",
|
||||
)
|
||||
async def verify_signature(
|
||||
key_id: uuid.UUID,
|
||||
data: VerifySignatureRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> VerifySignatureResponse:
|
||||
"""Verify a signature with an SSH key's public key.
|
||||
|
||||
Args:
|
||||
key_id: UUID of the SSH key to use for verification.
|
||||
data: Verify request containing payload and base64-encoded signature.
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Whether the signature is valid.
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
ssh_key = await session.get(SSHKey, key_id)
|
||||
if ssh_key is None or ssh_key.user_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||
|
||||
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
|
||||
|
||||
try:
|
||||
signature = base64.b64decode(data.signature)
|
||||
public_key.verify(signature, data.payload.encode())
|
||||
return VerifySignatureResponse(valid=True)
|
||||
except Exception:
|
||||
return VerifySignatureResponse(valid=False)
|
||||
|
||||
+50
-641
@@ -1,102 +1,65 @@
|
||||
"""WebSocket terminal endpoint for tool instances."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||
from sqlalchemy import select
|
||||
from fastapi import APIRouter, Depends, WebSocket
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.terminal_session import TerminalSessionModel
|
||||
from src.auth.dependencies import get_db_session
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager
|
||||
from src.services.terminal_manager import terminal_manager
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SessionRef:
|
||||
"""Mutable reference to a terminal session, allowing updates during reset."""
|
||||
|
||||
def __init__(self, session, slot_session_id: str | None = None):
|
||||
self.session = session
|
||||
self.slot_session_id = slot_session_id or session.session_id
|
||||
|
||||
|
||||
@router.websocket(
|
||||
"/ws/tool-instances/{instance_id}/terminal",
|
||||
)
|
||||
async def terminal_websocket_default(
|
||||
async def terminal_websocket(
|
||||
websocket: WebSocket,
|
||||
instance_id: str,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""WebSocket endpoint for terminal access (default session alias).
|
||||
"""WebSocket endpoint for terminal access to a tool instance.
|
||||
|
||||
Backward-compatible route that maps to the default session.
|
||||
"""
|
||||
await _handle_terminal_websocket(websocket, instance_id, None, db_session)
|
||||
|
||||
|
||||
@router.websocket(
|
||||
"/ws/tool-instances/{instance_id}/terminal/{session_id}",
|
||||
)
|
||||
async def terminal_websocket_specific(
|
||||
websocket: WebSocket,
|
||||
instance_id: str,
|
||||
session_id: str,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""WebSocket endpoint for a specific terminal session."""
|
||||
await _handle_terminal_websocket(websocket, instance_id, session_id, db_session)
|
||||
|
||||
|
||||
async def _handle_terminal_websocket(
|
||||
websocket: WebSocket,
|
||||
instance_id: str,
|
||||
target_session_id: str | None,
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Shared WebSocket handler for terminal sessions.
|
||||
Provides an interactive terminal session inside a running tool instance container.
|
||||
Supports:
|
||||
- Auto-reconnection (client reconnects, server spawns new session)
|
||||
- Heartbeat ping/pong
|
||||
- Binary and text input frames
|
||||
- Graceful session end notifications
|
||||
|
||||
Args:
|
||||
websocket: The WebSocket connection.
|
||||
instance_id: UUID string of the tool instance.
|
||||
target_session_id: Specific session ID (slot key). None means default session.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
None. Communicates via WebSocket messages.
|
||||
|
||||
"""
|
||||
logger.debug(
|
||||
"Terminal WebSocket connection attempt for instance %s (session=%s)",
|
||||
instance_id,
|
||||
target_session_id or "default",
|
||||
)
|
||||
logger.info("Terminal WebSocket connection attempt for instance %s", instance_id)
|
||||
await websocket.accept()
|
||||
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
|
||||
|
||||
try:
|
||||
# Parse instance_id
|
||||
instance_uuid = uuid.UUID(instance_id)
|
||||
except ValueError:
|
||||
logger.error("Invalid instance ID: %s", instance_id)
|
||||
await websocket.close(code=4001, reason="Invalid instance ID")
|
||||
return
|
||||
|
||||
# Authenticate user from session cookie
|
||||
user_id = await _get_user_from_websocket(websocket, db_session)
|
||||
if user_id is None:
|
||||
logger.warning(
|
||||
"Unauthorized terminal access attempt for instance %s", instance_id
|
||||
"Unauthorized terminal access attempt for instance %s",
|
||||
instance_id,
|
||||
)
|
||||
await websocket.close(code=4003, reason="Unauthorized")
|
||||
return
|
||||
|
||||
# Get instance and verify ownership
|
||||
instance = await db_session.get(ToolInstance, instance_uuid)
|
||||
if instance is None:
|
||||
logger.warning("Instance %s not found", instance_id)
|
||||
@@ -122,605 +85,50 @@ async def _handle_terminal_websocket(
|
||||
await websocket.close(code=4004, reason="Instance not running")
|
||||
return
|
||||
|
||||
logger.debug("Terminal auth passed for instance %s, user %s", instance_id, user_id)
|
||||
|
||||
# Verify the container actually exists (may have been removed/recreated)
|
||||
from src.services.docker import get_container_status
|
||||
|
||||
container_status = get_container_status(instance.container_id)
|
||||
if container_status["status"] == "not_found":
|
||||
logger.error(
|
||||
"Container %s for instance %s not found (may have been removed)",
|
||||
instance.container_id,
|
||||
instance_id,
|
||||
)
|
||||
await websocket.close(
|
||||
code=4004, reason="Container not found — restart the tool instance"
|
||||
)
|
||||
return
|
||||
|
||||
# Fetch tool type to get startup_command
|
||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||
startup_command = tool_type.startup_command if tool_type else None
|
||||
if startup_command:
|
||||
logger.debug(
|
||||
"Using startup command for instance %s: %s",
|
||||
instance_id,
|
||||
startup_command,
|
||||
)
|
||||
|
||||
session = None
|
||||
|
||||
# Get or create terminal session
|
||||
logger.info(
|
||||
"Creating terminal session for instance %s (container_id=%s)",
|
||||
instance_id,
|
||||
instance.container_id,
|
||||
)
|
||||
try:
|
||||
if target_session_id is None:
|
||||
# Default session alias
|
||||
session = await terminal_manager.get_or_create_session(
|
||||
instance_uuid,
|
||||
instance.container_id,
|
||||
startup_command=startup_command,
|
||||
)
|
||||
slot_session_id = "default"
|
||||
else:
|
||||
# Specific session
|
||||
session = terminal_manager.get_session(
|
||||
instance_id,
|
||||
target_session_id,
|
||||
)
|
||||
if session is None:
|
||||
# Session not in memory — may have been lost on server restart.
|
||||
# Try to restore from the DB row.
|
||||
db_row = await db_session.get(
|
||||
TerminalSessionModel, uuid.UUID(target_session_id)
|
||||
)
|
||||
if (
|
||||
db_row is not None
|
||||
and db_row.instance_id == instance_uuid
|
||||
and db_row.status != "closed"
|
||||
):
|
||||
logger.info(
|
||||
"Restoring terminal session %s for instance %s from DB",
|
||||
target_session_id,
|
||||
instance_id,
|
||||
)
|
||||
session = await terminal_manager.create_session(
|
||||
instance_uuid,
|
||||
instance.container_id,
|
||||
startup_command=startup_command,
|
||||
name=db_row.name,
|
||||
session_id=target_session_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Session %s not found for instance %s",
|
||||
target_session_id,
|
||||
instance_id,
|
||||
)
|
||||
await websocket.close(code=4004, reason="Session not found")
|
||||
return
|
||||
# Determine slot key for reset scoping
|
||||
key = terminal_manager._find_key_by_internal_id(
|
||||
instance_id, session.session_id
|
||||
)
|
||||
slot_session_id = key[1] if key else target_session_id
|
||||
|
||||
logger.debug(
|
||||
"Terminal session ready for instance %s (session_id=%s, slot=%s)",
|
||||
instance_id,
|
||||
session.session_id,
|
||||
slot_session_id,
|
||||
session = await terminal_manager.create_session(
|
||||
instance_uuid,
|
||||
instance.container_id,
|
||||
websocket,
|
||||
)
|
||||
logger.info(
|
||||
"Terminal session created successfully for instance %s",
|
||||
instance_id,
|
||||
)
|
||||
|
||||
# Attach WebSocket to session
|
||||
await terminal_manager.attach_websocket(session, websocket)
|
||||
logger.debug("WebSocket attached to session for instance %s", instance_id)
|
||||
|
||||
# Send connected status
|
||||
await websocket.send_json({"type": "status", "status": "connected"})
|
||||
logger.debug("Sent connected status for instance %s", instance_id)
|
||||
|
||||
# Use mutable session reference so loops can survive reset
|
||||
session_ref = SessionRef(session, slot_session_id)
|
||||
|
||||
# Start write loop and heartbeat (read is now event-driven in TerminalSession)
|
||||
write_task = asyncio.create_task(
|
||||
_write_loop(session_ref, websocket, instance_id)
|
||||
)
|
||||
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
||||
logger.debug("Started terminal loops for instance %s", instance_id)
|
||||
|
||||
# Wait for either task to complete (indicating disconnect or error)
|
||||
done, pending = await asyncio.wait(
|
||||
[write_task, heartbeat_task],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Terminal loop completed for instance %s, done=%s",
|
||||
instance_id,
|
||||
len(done),
|
||||
)
|
||||
|
||||
# Cancel remaining tasks
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.debug("WebSocket disconnected for instance %s", instance_id)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Terminal session error for instance %s: %s",
|
||||
instance_id,
|
||||
str(exc),
|
||||
exc_info=True,
|
||||
)
|
||||
with suppress(Exception):
|
||||
await websocket.close(code=4000, reason=f"Error: {exc}")
|
||||
finally:
|
||||
# Detach WebSocket, don't kill session
|
||||
with suppress(Exception):
|
||||
if session is not None:
|
||||
await terminal_manager.detach_websocket(session, websocket)
|
||||
logger.debug(
|
||||
"WebSocket detached from session for instance %s", instance_id
|
||||
# Monitor session health and echo state
|
||||
while session.is_alive() and not session.closed:
|
||||
# Check echo state periodically
|
||||
new_echo_state = await session.check_echo_state()
|
||||
if new_echo_state is not None:
|
||||
await websocket.send_json(
|
||||
{"type": "set_echo_state", "enabled": new_echo_state},
|
||||
)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
# Session ended — determine reason and notify client
|
||||
exit_reason = session.get_exit_reason() or "process_exit"
|
||||
await websocket.send_json({"type": "session_ended", "reason": exit_reason})
|
||||
await websocket.close(code=1000, reason=f"Session ended: {exit_reason}")
|
||||
|
||||
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
||||
"""Read input from WebSocket and send to container."""
|
||||
try:
|
||||
while True:
|
||||
session = session_ref.session
|
||||
if not session.is_alive() or session._closed:
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
message = await websocket.receive()
|
||||
if message["type"] == "websocket.receive":
|
||||
if "bytes" in message:
|
||||
await session.write_input(message["bytes"])
|
||||
elif "text" in message:
|
||||
text = message["text"]
|
||||
if text.startswith("{"):
|
||||
# Control message (JSON)
|
||||
try:
|
||||
ctrl = json.loads(text)
|
||||
msg_type = ctrl.get("type")
|
||||
|
||||
if msg_type == "resize":
|
||||
cols = ctrl.get("cols", 80)
|
||||
rows = ctrl.get("rows", 24)
|
||||
logger.debug(
|
||||
"Received resize message for instance %s: %sx%s",
|
||||
instance_id,
|
||||
cols,
|
||||
rows,
|
||||
)
|
||||
await session.resize(cols, rows)
|
||||
elif msg_type == "ack":
|
||||
char_count = ctrl.get("chars", 0)
|
||||
if char_count > 0:
|
||||
session.acknowledge_data(char_count)
|
||||
elif msg_type == "reset":
|
||||
# Reset terminal session (scoped to current slot)
|
||||
logger.debug(
|
||||
"Resetting terminal session for instance %s (slot=%s)",
|
||||
session.instance_id,
|
||||
session_ref.slot_session_id,
|
||||
)
|
||||
await websocket.send_json(
|
||||
{"type": "status", "status": "resetting"}
|
||||
)
|
||||
|
||||
# Reset the session scoped to its slot
|
||||
new_session = await terminal_manager.reset_session(
|
||||
session.instance_id,
|
||||
session.container_id,
|
||||
startup_command=session.startup_command,
|
||||
session_id=session_ref.slot_session_id,
|
||||
name=session.name,
|
||||
)
|
||||
|
||||
# Update the mutable session reference
|
||||
session_ref.session = new_session
|
||||
|
||||
# Attach to new session
|
||||
await terminal_manager.attach_websocket(
|
||||
new_session, websocket
|
||||
)
|
||||
await websocket.send_json(
|
||||
{"type": "status", "status": "connected"}
|
||||
)
|
||||
|
||||
# Continue the loop with the new session
|
||||
continue
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Not a valid JSON control message, treat as regular input
|
||||
await session.write_input(text.encode("utf-8"))
|
||||
else:
|
||||
await session.write_input(text.encode("utf-8"))
|
||||
elif message["type"] == "websocket.disconnect":
|
||||
break
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Terminal session error for instance %s",
|
||||
instance_id,
|
||||
)
|
||||
await websocket.close(code=4000, reason="Terminal session error")
|
||||
finally:
|
||||
pass
|
||||
|
||||
|
||||
async def _heartbeat_loop(websocket: WebSocket) -> None:
|
||||
"""Send periodic ping messages to detect disconnections."""
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(30) # Ping every 30 seconds
|
||||
try:
|
||||
await websocket.send_json({"type": "ping"})
|
||||
except Exception:
|
||||
# WebSocket is closed or broken
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _get_terminal_instance(
|
||||
instance_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
db_session: AsyncSession,
|
||||
) -> ToolInstance:
|
||||
"""Fetch instance and validate auth, ownership, and running status.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
The validated ToolInstance.
|
||||
|
||||
Raises:
|
||||
HTTPException: If instance not found, not owned, or not running.
|
||||
"""
|
||||
instance = await db_session.get(ToolInstance, instance_id)
|
||||
if instance is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Instance not found"
|
||||
)
|
||||
|
||||
if instance.owner_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to access this instance",
|
||||
)
|
||||
|
||||
if instance.status != "running" or not instance.container_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Instance is not running"
|
||||
)
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
@router.get(
|
||||
"/instances/{instance_id}/terminal/sessions",
|
||||
summary="List terminal sessions",
|
||||
description="List terminal sessions for a tool instance with live WebSocket state.",
|
||||
)
|
||||
async def list_terminal_sessions(
|
||||
instance_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""List terminal sessions for an instance.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with sessions list.
|
||||
"""
|
||||
await _get_terminal_instance(instance_id, user_id, db_session)
|
||||
|
||||
# Query active DB rows for this instance
|
||||
result = await db_session.execute(
|
||||
select(TerminalSessionModel)
|
||||
.where(TerminalSessionModel.instance_id == instance_id)
|
||||
.where(TerminalSessionModel.status != "closed")
|
||||
.order_by(TerminalSessionModel.created_at.asc())
|
||||
)
|
||||
db_rows = result.scalars().all()
|
||||
|
||||
# Build response with live has_websockets flag.
|
||||
# Include DB rows even without in-memory counterparts (e.g. after
|
||||
# server restart) so the frontend can display tabs and reconnect.
|
||||
sessions = []
|
||||
for row in db_rows:
|
||||
live_session = terminal_manager.get_session(str(instance_id), str(row.id))
|
||||
sessions.append(
|
||||
{
|
||||
"id": str(row.id),
|
||||
"name": row.name,
|
||||
"status": row.status,
|
||||
"has_websockets": live_session.has_websockets()
|
||||
if live_session
|
||||
else False,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"last_activity_at": row.last_activity_at.isoformat()
|
||||
if row.last_activity_at
|
||||
else None,
|
||||
}
|
||||
)
|
||||
|
||||
return {"sessions": sessions}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/terminal/sessions",
|
||||
summary="Create terminal session",
|
||||
description="Create a new terminal session for a running tool instance.",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_terminal_session(
|
||||
instance_id: uuid.UUID,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Create a new terminal session.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
data: Request body with optional name.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with new session details.
|
||||
|
||||
Raises:
|
||||
HTTPException: 409 if max sessions reached.
|
||||
"""
|
||||
instance = await _get_terminal_instance(instance_id, user_id, db_session)
|
||||
assert instance.container_id is not None
|
||||
|
||||
# Fetch tool type to get startup_command
|
||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||
startup_command = tool_type.startup_command if tool_type else None
|
||||
|
||||
name = data.get("name")
|
||||
|
||||
try:
|
||||
session = await terminal_manager.create_session(
|
||||
instance_id,
|
||||
instance.container_id,
|
||||
startup_command=startup_command,
|
||||
name=name,
|
||||
)
|
||||
except MaxSessionsExceededError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Maximum of 5 terminal sessions reached for this instance",
|
||||
) from None
|
||||
|
||||
return {
|
||||
"id": session.session_id,
|
||||
"name": session.name,
|
||||
"status": session.status,
|
||||
"created_at": session.last_activity,
|
||||
}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/instances/{instance_id}/terminal/sessions/{session_id}",
|
||||
summary="Close terminal session",
|
||||
description="Close a specific terminal session.",
|
||||
)
|
||||
async def close_terminal_session(
|
||||
instance_id: uuid.UUID,
|
||||
session_id: str,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Close a terminal session.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
session_id: ID of the session to close.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with closure status.
|
||||
"""
|
||||
await _get_terminal_instance(instance_id, user_id, db_session)
|
||||
|
||||
# Find the session by internal ID to determine its slot key
|
||||
key = terminal_manager._find_key_by_internal_id(str(instance_id), session_id)
|
||||
if (
|
||||
key is None
|
||||
and terminal_manager.get_session(str(instance_id), session_id) is not None
|
||||
):
|
||||
key = (str(instance_id), session_id)
|
||||
|
||||
if key is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||
)
|
||||
|
||||
await terminal_manager.close_session(key[0], key[1])
|
||||
|
||||
return {"status": "closed", "session_id": session_id}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/terminal/sessions/{session_id}/reset",
|
||||
summary="Reset terminal session",
|
||||
description="Reset a specific terminal session, killing the current shell and starting fresh.",
|
||||
)
|
||||
async def reset_specific_terminal_session(
|
||||
instance_id: uuid.UUID,
|
||||
session_id: str,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Reset a specific terminal session.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
session_id: ID of the session to reset.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with reset session details.
|
||||
"""
|
||||
instance = await _get_terminal_instance(instance_id, user_id, db_session)
|
||||
assert instance.container_id is not None
|
||||
|
||||
# Determine slot key for reset
|
||||
key = terminal_manager._find_key_by_internal_id(str(instance_id), session_id)
|
||||
if (
|
||||
key is None
|
||||
and terminal_manager.get_session(str(instance_id), session_id) is not None
|
||||
):
|
||||
key = (str(instance_id), session_id)
|
||||
|
||||
if key is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||
)
|
||||
|
||||
# Fetch tool type to get startup_command
|
||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||
startup_command = tool_type.startup_command if tool_type else None
|
||||
|
||||
# Preserve name if possible
|
||||
live_session = terminal_manager.get_session(str(instance_id), session_id)
|
||||
name = live_session.name if live_session else None
|
||||
|
||||
new_session = await terminal_manager.reset_session(
|
||||
instance_id,
|
||||
instance.container_id,
|
||||
startup_command=startup_command,
|
||||
session_id=key[1],
|
||||
name=name,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": new_session.session_id,
|
||||
"name": new_session.name,
|
||||
"status": new_session.status,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/terminal/sessions/{session_id}/rename",
|
||||
summary="Rename terminal session",
|
||||
description="Rename a specific terminal session.",
|
||||
)
|
||||
async def rename_terminal_session(
|
||||
instance_id: uuid.UUID,
|
||||
session_id: str,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Rename a terminal session.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
session_id: ID of the session to rename.
|
||||
data: Request body with new name.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with updated session details.
|
||||
"""
|
||||
await _get_terminal_instance(instance_id, user_id, db_session)
|
||||
|
||||
new_name = data.get("name")
|
||||
if not new_name or not isinstance(new_name, str):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Name is required"
|
||||
)
|
||||
|
||||
# Update in-memory session name if live
|
||||
live_session = terminal_manager.get_session(str(instance_id), session_id)
|
||||
if live_session:
|
||||
live_session.name = new_name
|
||||
|
||||
# Update DB row
|
||||
db_row = await db_session.get(TerminalSessionModel, uuid.UUID(session_id))
|
||||
if db_row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||
)
|
||||
|
||||
db_row.name = new_name
|
||||
await db_session.commit()
|
||||
|
||||
return {"id": str(db_row.id), "name": new_name}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/terminal/reset",
|
||||
summary="Reset terminal session (legacy alias)",
|
||||
description="Reset the default terminal session for a tool instance. Preserved for backward compatibility.",
|
||||
)
|
||||
async def reset_terminal_session(
|
||||
instance_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Reset the default terminal session for an instance (legacy alias).
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
user_id: ID of the authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with status message.
|
||||
"""
|
||||
instance = await _get_terminal_instance(instance_id, user_id, db_session)
|
||||
assert instance.container_id is not None
|
||||
|
||||
# Fetch tool type to get startup_command
|
||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||
startup_command = tool_type.startup_command if tool_type else None
|
||||
|
||||
try:
|
||||
# Reset the default session
|
||||
new_session = await terminal_manager.reset_session(
|
||||
instance_id,
|
||||
instance.container_id,
|
||||
startup_command=startup_command,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Terminal session reset for instance %s (new session_id=%s)",
|
||||
instance_id,
|
||||
new_session.session_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Terminal session reset successfully",
|
||||
"instance_id": str(instance_id),
|
||||
"session_id": new_session.session_id,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to reset terminal session for instance %s: %s",
|
||||
instance_id,
|
||||
str(exc),
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to reset terminal session: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
async def _get_user_from_websocket(
|
||||
websocket: WebSocket,
|
||||
db_session: AsyncSession,
|
||||
@@ -733,6 +141,7 @@ async def _get_user_from_websocket(
|
||||
|
||||
Returns:
|
||||
The user's UUID if authenticated, None otherwise.
|
||||
|
||||
"""
|
||||
from src.auth.session import decode_session_cookie
|
||||
from src.config import Settings
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Tool configuration API endpoints."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.tool_config import ToolConfig
|
||||
from src.models.tool_type import ToolType
|
||||
from src.schemas.tool_config import ToolConfigCreate, ToolConfigUpdate, ToolConfigResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
||||
|
||||
|
||||
@router.get("", summary="List tool configs", description="Get all tool configs for the current user.")
|
||||
async def list_configs(
|
||||
tool_type_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list:
|
||||
"""List tool configs for the current user."""
|
||||
query = select(ToolConfig).where(ToolConfig.user_id == user_id)
|
||||
|
||||
if tool_type_id:
|
||||
query = query.where(ToolConfig.tool_type_id == uuid.UUID(tool_type_id))
|
||||
if project_id:
|
||||
query = query.where(ToolConfig.project_id == uuid.UUID(project_id))
|
||||
else:
|
||||
# If no project specified, get only global configs (project_id is None)
|
||||
query = query.where(ToolConfig.project_id.is_(None))
|
||||
|
||||
result = await session.execute(query)
|
||||
configs = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(c.id),
|
||||
"tool_type_id": str(c.tool_type_id),
|
||||
"project_id": str(c.project_id) if c.project_id else None,
|
||||
"key": c.key,
|
||||
"value": c.value,
|
||||
"config_type": c.config_type,
|
||||
"file_path": c.file_path,
|
||||
"port_override": c.port_override,
|
||||
"start_command": c.start_command,
|
||||
"working_directory": c.working_directory,
|
||||
"environment_variables": c.environment_variables,
|
||||
"volumes": c.volumes,
|
||||
}
|
||||
for c in configs
|
||||
]
|
||||
|
||||
|
||||
@router.post("", summary="Create tool config", description="Create a new tool config.", status_code=status.HTTP_201_CREATED)
|
||||
async def create_config(
|
||||
data: ToolConfigCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Create a tool config."""
|
||||
# Verify tool type exists
|
||||
tool_type = await session.get(ToolType, uuid.UUID(data.tool_type_id))
|
||||
if tool_type is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||
|
||||
# Check for existing config with same key
|
||||
query = select(ToolConfig).where(
|
||||
ToolConfig.user_id == user_id,
|
||||
ToolConfig.tool_type_id == uuid.UUID(data.tool_type_id),
|
||||
ToolConfig.key == data.key,
|
||||
)
|
||||
if data.project_id:
|
||||
query = query.where(ToolConfig.project_id == uuid.UUID(data.project_id))
|
||||
else:
|
||||
query = query.where(ToolConfig.project_id.is_(None))
|
||||
|
||||
existing = await session.scalar(query)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"config with key '{data.key}' already exists"
|
||||
)
|
||||
|
||||
config = ToolConfig(
|
||||
user_id=user_id,
|
||||
tool_type_id=uuid.UUID(data.tool_type_id),
|
||||
project_id=uuid.UUID(data.project_id) if data.project_id else None,
|
||||
key=data.key,
|
||||
value=data.value,
|
||||
config_type=data.config_type,
|
||||
file_path=data.file_path,
|
||||
port_override=data.port_override,
|
||||
start_command=data.start_command,
|
||||
working_directory=data.working_directory,
|
||||
environment_variables=data.environment_variables,
|
||||
volumes=data.volumes,
|
||||
)
|
||||
session.add(config)
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
|
||||
return {
|
||||
"id": str(config.id),
|
||||
"tool_type_id": str(config.tool_type_id),
|
||||
"project_id": str(config.project_id) if config.project_id else None,
|
||||
"key": config.key,
|
||||
"value": config.value,
|
||||
"config_type": config.config_type,
|
||||
"file_path": config.file_path,
|
||||
"port_override": config.port_override,
|
||||
"start_command": config.start_command,
|
||||
"working_directory": config.working_directory,
|
||||
"environment_variables": config.environment_variables,
|
||||
"volumes": config.volumes,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{config_id}", summary="Update tool config", description="Update an existing tool config.")
|
||||
async def update_config(
|
||||
config_id: uuid.UUID,
|
||||
data: ToolConfigUpdate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Update a tool config."""
|
||||
config = await session.get(ToolConfig, config_id)
|
||||
if config is None or config.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
|
||||
|
||||
if data.key is not None:
|
||||
config.key = data.key
|
||||
if data.value is not None:
|
||||
config.value = data.value
|
||||
if data.config_type is not None:
|
||||
config.config_type = data.config_type
|
||||
if data.file_path is not None:
|
||||
config.file_path = data.file_path
|
||||
if data.port_override is not None:
|
||||
config.port_override = data.port_override
|
||||
if data.start_command is not None:
|
||||
config.start_command = data.start_command
|
||||
if data.working_directory is not None:
|
||||
config.working_directory = data.working_directory
|
||||
if data.environment_variables is not None:
|
||||
config.environment_variables = data.environment_variables
|
||||
if data.volumes is not None:
|
||||
config.volumes = data.volumes
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
|
||||
return {
|
||||
"id": str(config.id),
|
||||
"tool_type_id": str(config.tool_type_id),
|
||||
"project_id": str(config.project_id) if config.project_id else None,
|
||||
"key": config.key,
|
||||
"value": config.value,
|
||||
"config_type": config.config_type,
|
||||
"file_path": config.file_path,
|
||||
"port_override": config.port_override,
|
||||
"start_command": config.start_command,
|
||||
"working_directory": config.working_directory,
|
||||
"environment_variables": config.environment_variables,
|
||||
"volumes": config.volumes,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/defaults/{tool_type_id}", summary="Get default configs", description="Get suggested default configs for a tool type.")
|
||||
async def get_default_configs(
|
||||
tool_type_id: str,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get suggested default configs for a tool type."""
|
||||
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
|
||||
if tool_type is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||
|
||||
# Return suggested defaults based on required_variables
|
||||
defaults = []
|
||||
for var in tool_type.required_variables:
|
||||
defaults.append({
|
||||
"key": var,
|
||||
"value": "",
|
||||
"config_type": "env",
|
||||
"description": f"Required variable: {var}",
|
||||
})
|
||||
|
||||
return {
|
||||
"tool_type_id": tool_type_id,
|
||||
"suggested_configs": defaults,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{config_id}", summary="Delete tool config", description="Delete a tool config.")
|
||||
async def delete_config(
|
||||
config_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""Delete a tool config."""
|
||||
config = await session.get(ToolConfig, config_id)
|
||||
if config is None or config.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
|
||||
|
||||
await session.delete(config)
|
||||
await session.commit()
|
||||
+150
-2847
File diff suppressed because it is too large
Load Diff
+95
-332
@@ -1,19 +1,15 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.tool_types_validation import (
|
||||
check_port_exposed,
|
||||
validate_compose_yaml,
|
||||
validate_required_variables,
|
||||
)
|
||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||
from src.auth.dependencies import get_current_user, get_db_session
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.schemas.tool_type import ToolTypeCreate, ToolTypeResponse, ToolTypeUpdate, ToolTypeValidateRequest
|
||||
|
||||
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
||||
|
||||
@@ -29,237 +25,6 @@ async def _require_admin(user: User) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class ToolTypeCreate(BaseModel):
|
||||
name: str
|
||||
display_name: str
|
||||
description: str | None = None
|
||||
default_port: int = 0
|
||||
definition_type: str = "compose"
|
||||
manifest_id: uuid.UUID | None = None
|
||||
compose_template: str | None = None
|
||||
dockerfile_template: str | None = None
|
||||
build_context: dict | None = None
|
||||
readiness_probe: dict | None = None
|
||||
startup_command: str | None = None
|
||||
required_variables: list[str] = []
|
||||
category: str = "other"
|
||||
interface_type: str = "web"
|
||||
requires_port: bool = True
|
||||
|
||||
@field_validator("definition_type")
|
||||
@classmethod
|
||||
def validate_definition_type(cls, v: str) -> str:
|
||||
if v not in ("compose", "dockerfile", "manifest"):
|
||||
raise ValueError(
|
||||
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("compose_template")
|
||||
@classmethod
|
||||
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 or not v.strip():
|
||||
raise ValueError(
|
||||
"compose_template is required when definition_type is 'compose'"
|
||||
)
|
||||
|
||||
validate_compose_yaml(v)
|
||||
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 or not v.strip():
|
||||
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("interface_type")
|
||||
@classmethod
|
||||
def validate_interface_type(cls, v: str) -> str:
|
||||
if v not in ("web", "terminal"):
|
||||
raise ValueError("interface_type must be 'web' or 'terminal'")
|
||||
return v
|
||||
|
||||
@field_validator("default_port")
|
||||
@classmethod
|
||||
def validate_default_port(cls, v: int, info) -> int:
|
||||
data = info.data
|
||||
requires_port = data.get("requires_port", True)
|
||||
if not requires_port:
|
||||
return v
|
||||
if v <= 0 or v > 65535:
|
||||
raise ValueError("Port must be between 1 and 65535")
|
||||
return v
|
||||
|
||||
@field_validator("required_variables")
|
||||
@classmethod
|
||||
def validate_required_variables(cls, v: list[str], info) -> list[str]:
|
||||
if not v:
|
||||
return v
|
||||
|
||||
data = info.data
|
||||
if data.get("definition_type") != "compose":
|
||||
return v
|
||||
|
||||
template = data.get("compose_template")
|
||||
if not template:
|
||||
return v
|
||||
|
||||
for var in v:
|
||||
placeholder = f"{{{{{var}}}}}"
|
||||
if placeholder not in template:
|
||||
raise ValueError(
|
||||
f"Required variable '{var}' not found in compose template"
|
||||
)
|
||||
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_templates(self) -> "ToolTypeCreate":
|
||||
if self.definition_type == "manifest":
|
||||
if self.manifest_id is None:
|
||||
raise ValueError(
|
||||
"manifest_id is required when definition_type is 'manifest'"
|
||||
)
|
||||
return self
|
||||
|
||||
if self.definition_type == "dockerfile" and (
|
||||
self.dockerfile_template is None or not self.dockerfile_template.strip()
|
||||
):
|
||||
raise ValueError(
|
||||
"dockerfile_template is required when definition_type is 'dockerfile'"
|
||||
)
|
||||
if self.definition_type == "compose" and (
|
||||
self.compose_template is None or not self.compose_template.strip()
|
||||
):
|
||||
raise ValueError(
|
||||
"compose_template is required when definition_type is 'compose'"
|
||||
)
|
||||
|
||||
# Validate that default_port is exposed in compose template (only if requires_port)
|
||||
if (
|
||||
self.requires_port
|
||||
and self.definition_type == "compose"
|
||||
and self.compose_template
|
||||
):
|
||||
try:
|
||||
parsed = validate_compose_yaml(self.compose_template)
|
||||
except ValueError:
|
||||
return self
|
||||
|
||||
if not check_port_exposed(parsed, self.default_port):
|
||||
raise ValueError(
|
||||
f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section."
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class ToolTypeUpdate(BaseModel):
|
||||
display_name: str | None = None
|
||||
description: str | None = None
|
||||
default_port: int | None = None
|
||||
definition_type: str | None = None
|
||||
manifest_id: uuid.UUID | None = None
|
||||
compose_template: str | None = None
|
||||
dockerfile_template: str | None = None
|
||||
build_context: dict | None = None
|
||||
readiness_probe: dict | None = None
|
||||
startup_command: str | None = None
|
||||
required_variables: list[str] | None = None
|
||||
category: str | None = None
|
||||
interface_type: str | None = None
|
||||
requires_port: bool | 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", "manifest"):
|
||||
raise ValueError(
|
||||
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("interface_type")
|
||||
@classmethod
|
||||
def validate_interface_type(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if v not in ("web", "terminal"):
|
||||
raise ValueError("interface_type must be 'web' or 'terminal'")
|
||||
return v
|
||||
|
||||
@field_validator("compose_template")
|
||||
@classmethod
|
||||
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
|
||||
|
||||
validate_compose_yaml(v)
|
||||
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)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
display_name: str
|
||||
description: str | None
|
||||
category: str
|
||||
interface_type: str
|
||||
requires_port: bool
|
||||
default_port: int
|
||||
definition_type: str
|
||||
manifest_id: uuid.UUID | None
|
||||
compose_template: str | None
|
||||
dockerfile_template: str | None
|
||||
build_context: dict | None
|
||||
readiness_probe: dict | None
|
||||
startup_command: str | None
|
||||
required_variables: list[str]
|
||||
created_by_id: uuid.UUID | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ToolTypeResponse,
|
||||
@@ -269,7 +34,7 @@ class ToolTypeResponse(BaseModel):
|
||||
)
|
||||
async def create_tool_type(
|
||||
data: ToolTypeCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> ToolType:
|
||||
"""Create a new tool type.
|
||||
@@ -282,33 +47,27 @@ async def create_tool_type(
|
||||
Returns:
|
||||
The newly created tool type.
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
await _require_admin(user)
|
||||
|
||||
|
||||
# Check for duplicate name
|
||||
existing = await session.scalar(select(ToolType).where(ToolType.name == data.name))
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="tool type with this name already exists",
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="tool type with this name already exists")
|
||||
|
||||
tool_type = ToolType(
|
||||
name=data.name,
|
||||
display_name=data.display_name,
|
||||
description=data.description,
|
||||
default_port=data.default_port,
|
||||
definition_type=data.definition_type,
|
||||
manifest_id=data.manifest_id,
|
||||
compose_template=data.compose_template,
|
||||
dockerfile_template=data.dockerfile_template,
|
||||
build_context=data.build_context,
|
||||
readiness_probe=data.readiness_probe,
|
||||
startup_command=data.startup_command,
|
||||
required_variables=data.required_variables,
|
||||
category=data.category,
|
||||
interface_type=data.interface_type,
|
||||
requires_port=data.requires_port,
|
||||
interfaces=data.interfaces,
|
||||
is_builtin=False,
|
||||
created_by_id=user.id,
|
||||
)
|
||||
session.add(tool_type)
|
||||
@@ -324,7 +83,7 @@ async def create_tool_type(
|
||||
description="List all available tool types including built-in and custom ones.",
|
||||
)
|
||||
async def list_tool_types(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[ToolType]:
|
||||
"""List all tool types.
|
||||
@@ -336,7 +95,6 @@ async def list_tool_types(
|
||||
Returns:
|
||||
List of all tool types ordered by name.
|
||||
"""
|
||||
await _get_user(session, user_id)
|
||||
result = await session.execute(select(ToolType).order_by(ToolType.name))
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -349,7 +107,7 @@ async def list_tool_types(
|
||||
)
|
||||
async def get_tool_type(
|
||||
tool_type_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> ToolType:
|
||||
"""Get a specific tool type by ID.
|
||||
@@ -362,12 +120,9 @@ async def get_tool_type(
|
||||
Returns:
|
||||
The requested tool type.
|
||||
"""
|
||||
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"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||
return tool_type
|
||||
|
||||
|
||||
@@ -380,7 +135,7 @@ async def get_tool_type(
|
||||
async def update_tool_type(
|
||||
tool_type_id: uuid.UUID,
|
||||
data: ToolTypeUpdate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> ToolType:
|
||||
"""Update a tool type.
|
||||
@@ -394,79 +149,88 @@ async def update_tool_type(
|
||||
Returns:
|
||||
The updated tool type.
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
await _require_admin(user)
|
||||
|
||||
|
||||
tool_type = await session.get(ToolType, tool_type_id)
|
||||
if tool_type is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||
)
|
||||
|
||||
# Built-in tool types can now be modified
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||
|
||||
if tool_type.is_builtin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot modify built-in tool types")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
|
||||
# Validate port if being updated
|
||||
requires_port = update_data.get("requires_port", tool_type.requires_port)
|
||||
if "default_port" in update_data and requires_port:
|
||||
if "default_port" in update_data:
|
||||
new_port = update_data["default_port"]
|
||||
if new_port <= 0 or new_port > 65535:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Port must be between 1 and 65535",
|
||||
detail="Port must be between 1 and 65535"
|
||||
)
|
||||
|
||||
|
||||
# Only validate port exposure for compose definitions
|
||||
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
||||
if definition_type == "compose":
|
||||
template = update_data.get("compose_template", tool_type.compose_template)
|
||||
if template:
|
||||
try:
|
||||
parsed = validate_compose_yaml(template)
|
||||
if not check_port_exposed(parsed, new_port):
|
||||
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
|
||||
|
||||
if not port_exposed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Port {new_port} is not exposed in the compose template",
|
||||
detail=f"Port {new_port} is not exposed in the compose template"
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
# 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:
|
||||
validate_required_variables(
|
||||
update_data["compose_template"], update_data["required_variables"]
|
||||
)
|
||||
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:
|
||||
validate_required_variables(template, update_data["required_variables"])
|
||||
|
||||
# When switching to manifest, clear legacy templates
|
||||
if definition_type == "manifest":
|
||||
if "manifest_id" in update_data:
|
||||
tool_type.manifest_id = update_data["manifest_id"]
|
||||
tool_type.compose_template = None
|
||||
tool_type.dockerfile_template = None
|
||||
|
||||
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)
|
||||
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(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",
|
||||
@@ -474,7 +238,7 @@ class ToolTypeValidateRequest(BaseModel):
|
||||
)
|
||||
async def validate_tool_type_template(
|
||||
data: ToolTypeValidateRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Validate a tool type template syntax.
|
||||
@@ -487,7 +251,6 @@ async def validate_tool_type_template(
|
||||
Returns:
|
||||
Validation result with success status and any errors.
|
||||
"""
|
||||
await _get_user(session, user_id)
|
||||
|
||||
errors = []
|
||||
|
||||
@@ -496,9 +259,15 @@ async def validate_tool_type_template(
|
||||
errors.append("Compose template is required")
|
||||
else:
|
||||
try:
|
||||
validate_compose_yaml(data.compose_template)
|
||||
except ValueError as e:
|
||||
errors.append(str(e))
|
||||
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:
|
||||
@@ -506,11 +275,8 @@ async def validate_tool_type_template(
|
||||
elif not data.dockerfile_template.strip().startswith("FROM"):
|
||||
errors.append("Dockerfile must start with a FROM instruction")
|
||||
|
||||
elif data.definition_type == "manifest":
|
||||
pass # Manifest validation is handled separately
|
||||
|
||||
else:
|
||||
errors.append("definition_type must be 'compose', 'dockerfile', or 'manifest'")
|
||||
errors.append("definition_type must be 'compose' or 'dockerfile'")
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
@@ -525,7 +291,7 @@ async def validate_tool_type_template(
|
||||
)
|
||||
async def validate_tool_type(
|
||||
tool_type_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Validate a tool type's template syntax.
|
||||
@@ -538,34 +304,33 @@ async def validate_tool_type(
|
||||
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"
|
||||
)
|
||||
|
||||
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:
|
||||
validate_compose_yaml(tool_type.compose_template)
|
||||
except ValueError as e:
|
||||
errors.append(str(e))
|
||||
|
||||
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")
|
||||
|
||||
elif tool_type.definition_type == "manifest":
|
||||
if not tool_type.manifest_id:
|
||||
errors.append("Manifest reference is missing")
|
||||
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
"errors": errors,
|
||||
@@ -580,7 +345,7 @@ async def validate_tool_type(
|
||||
)
|
||||
async def delete_tool_type(
|
||||
tool_type_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""Delete a tool type.
|
||||
@@ -593,16 +358,14 @@ async def delete_tool_type(
|
||||
Returns:
|
||||
None with 204 status code.
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
await _require_admin(user)
|
||||
|
||||
|
||||
tool_type = await session.get(ToolType, tool_type_id)
|
||||
if tool_type is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||
)
|
||||
|
||||
# Built-in tool types can now be deleted
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||
|
||||
if tool_type.is_builtin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot delete built-in tool types")
|
||||
|
||||
await session.delete(tool_type)
|
||||
await session.commit()
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||
from src.auth.dependencies import get_current_user, get_db_session
|
||||
from src.models.user import User
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from src.schemas.user_config import UserConfigResponse, UserConfigUpdate
|
||||
|
||||
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
||||
|
||||
|
||||
async def _get_or_create_config(
|
||||
session: AsyncSession, user_id: uuid.UUID
|
||||
) -> UserConfig:
|
||||
|
||||
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
|
||||
"""Get or create user config record.
|
||||
|
||||
Args:
|
||||
@@ -26,40 +24,16 @@ async def _get_or_create_config(
|
||||
Returns:
|
||||
The user's config, creating a new one if it doesn't exist.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||
)
|
||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user.id))
|
||||
config = result.scalar_one_or_none()
|
||||
if config is None:
|
||||
config = UserConfig(user_id=user_id, config={})
|
||||
config = UserConfig(user_id=user.id, config={})
|
||||
session.add(config)
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
class UserConfigResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
default_editor: str | None = None
|
||||
theme: str = "system"
|
||||
git_user_name: str | None = None
|
||||
git_user_email: str | None = None
|
||||
last_session_id: str | None = None
|
||||
notification_mute_categories: list[str] | None = None
|
||||
notification_toast_level: str | None = None
|
||||
|
||||
|
||||
class UserConfigUpdate(BaseModel):
|
||||
default_editor: str | None = None
|
||||
theme: str | None = None
|
||||
git_user_name: str | None = None
|
||||
git_user_email: str | None = None
|
||||
last_session_id: str | None = None
|
||||
notification_mute_categories: list[str] | None = None
|
||||
notification_toast_level: str | None = None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/config",
|
||||
response_model=UserConfigResponse,
|
||||
@@ -67,7 +41,7 @@ class UserConfigUpdate(BaseModel):
|
||||
description="Get the current user's configuration settings.",
|
||||
)
|
||||
async def get_user_config(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> UserConfigResponse:
|
||||
"""Get the current user's configuration.
|
||||
@@ -79,8 +53,7 @@ async def get_user_config(
|
||||
Returns:
|
||||
The user's configuration settings.
|
||||
"""
|
||||
_user = await _get_user(session, user_id)
|
||||
config = await _get_or_create_config(session, user_id)
|
||||
config = await _get_or_create_config(session, user.id)
|
||||
return UserConfigResponse.model_validate(config.config)
|
||||
|
||||
|
||||
@@ -92,7 +65,7 @@ async def get_user_config(
|
||||
)
|
||||
async def update_user_config(
|
||||
data: UserConfigUpdate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> UserConfigResponse:
|
||||
"""Update the current user's configuration.
|
||||
@@ -105,16 +78,15 @@ async def update_user_config(
|
||||
Returns:
|
||||
The updated user configuration.
|
||||
"""
|
||||
_user = await _get_user(session, user_id)
|
||||
config = await _get_or_create_config(session, user_id)
|
||||
config = await _get_or_create_config(session, user.id)
|
||||
|
||||
# Merge updates
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
logger.debug("Updating user config for user %s: %s", user_id, update_data)
|
||||
logger.info("Updating user config for user %s: %s", user.id, update_data)
|
||||
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
||||
config.config = {**config.config, **update_data}
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
logger.debug("Updated config: %s", config.config)
|
||||
logger.info("Updated config: %s", config.config)
|
||||
return UserConfigResponse.model_validate(config.config)
|
||||
|
||||
+47
-21
@@ -2,11 +2,14 @@ import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||
from src.auth.dependencies import get_current_user, get_db_session
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.user import User
|
||||
from src.schemas.tool_instance import SessionItemResponse, SessionListResponse
|
||||
from src.schemas.user import UserProfileResponse, UserProfileUpdate
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
@@ -16,19 +19,6 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
|
||||
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
name: str
|
||||
avatar_url: str | None
|
||||
|
||||
|
||||
class UserProfileUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
@@ -37,7 +27,7 @@ class UserProfileUpdate(BaseModel):
|
||||
description="Retrieve the profile of the currently authenticated user.",
|
||||
)
|
||||
async def get_profile(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> User:
|
||||
"""Get the current user's profile.
|
||||
@@ -49,7 +39,7 @@ async def get_profile(
|
||||
Returns:
|
||||
The user's profile information.
|
||||
"""
|
||||
return await _get_user(session, user_id)
|
||||
return user
|
||||
|
||||
|
||||
@router.put(
|
||||
@@ -60,7 +50,7 @@ async def get_profile(
|
||||
)
|
||||
async def update_profile(
|
||||
data: UserProfileUpdate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> User:
|
||||
"""Update the current user's profile.
|
||||
@@ -73,7 +63,6 @@ async def update_profile(
|
||||
Returns:
|
||||
The updated user profile.
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
|
||||
if data.name is not None:
|
||||
if len(data.name.strip()) == 0:
|
||||
@@ -98,7 +87,7 @@ async def update_profile(
|
||||
)
|
||||
async def upload_avatar(
|
||||
file: UploadFile,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> User:
|
||||
"""Upload a profile avatar image.
|
||||
@@ -111,7 +100,6 @@ async def upload_avatar(
|
||||
Returns:
|
||||
The updated user profile with new avatar URL.
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
|
||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||
raise HTTPException(
|
||||
@@ -146,3 +134,41 @@ async def upload_avatar(
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me/sessions",
|
||||
response_model=SessionListResponse,
|
||||
summary="Get current user sessions",
|
||||
description="Retrieve all tool instances (sessions) for the authenticated user.",
|
||||
)
|
||||
async def get_user_sessions(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> SessionListResponse:
|
||||
"""Return all tool instances for the current user with related names."""
|
||||
result = await session.execute(
|
||||
select(ToolInstance)
|
||||
.where(ToolInstance.owner_id == user.id)
|
||||
.order_by(ToolInstance.created_at.desc())
|
||||
)
|
||||
instances = result.scalars().all()
|
||||
|
||||
sessions = [
|
||||
SessionItemResponse(
|
||||
id=str(inst.id),
|
||||
display_name=inst.display_name,
|
||||
tool_type_name=inst.tool_type.display_name if inst.tool_type else "Unknown",
|
||||
tool_icon=inst.tool_type.icon if inst.tool_type else None,
|
||||
tool_type_interfaces=inst.tool_type.interfaces if inst.tool_type else [],
|
||||
repository_name=inst.repository.name if inst.repository else "Unknown",
|
||||
repository_id=str(inst.repository_id),
|
||||
project_name=inst.project.name if inst.project else "Unknown",
|
||||
project_id=str(inst.project_id),
|
||||
status=inst.status,
|
||||
url=inst.url,
|
||||
)
|
||||
for inst in instances
|
||||
]
|
||||
|
||||
return SessionListResponse(sessions=sessions)
|
||||
|
||||
@@ -50,25 +50,17 @@ async def get_current_user(
|
||||
return user
|
||||
|
||||
|
||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
"""Fetch a user by ID or raise 401 if not found."""
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||
return user
|
||||
|
||||
|
||||
async def _get_owned_project(
|
||||
async def get_owned_project(
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
) -> "Project":
|
||||
user: User = Depends(get_current_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> Project:
|
||||
"""Fetch a project and verify ownership.
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project.
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
project_id: UUID of the project (injected from path parameter).
|
||||
user: The currently authenticated user.
|
||||
db_session: Database session.
|
||||
|
||||
Returns:
|
||||
The project if found and owned by the user.
|
||||
@@ -76,11 +68,9 @@ async def _get_owned_project(
|
||||
Raises:
|
||||
HTTPException: 404 if project not found, 403 if user is not the owner.
|
||||
"""
|
||||
from src.models.project import Project
|
||||
|
||||
project = await session.get(Project, project_id)
|
||||
project = await db_session.get(Project, project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
||||
if project.owner_id != user_id:
|
||||
if project.owner_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
||||
return project
|
||||
|
||||
@@ -1,52 +1,15 @@
|
||||
"""Structured JSON logging configuration."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from src.services.correlation import get_correlation_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CorrelationIdFilter(logging.Filter):
|
||||
"""Inject correlation_id into every log record from context var."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.correlation_id = get_correlation_id() # type: ignore[attr-defined]
|
||||
return True
|
||||
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
"""Emit log records as single-line JSON."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
log_obj: dict = {
|
||||
"timestamp": self.formatTime(record),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
"correlation_id": getattr(record, "correlation_id", None),
|
||||
}
|
||||
# Optional extra fields
|
||||
for key in ("instance_id", "event_type"):
|
||||
value = getattr(record, key, None)
|
||||
if value is not None:
|
||||
log_obj[key] = value
|
||||
if record.exc_info:
|
||||
log_obj["exception"] = self.formatException(record.exc_info)
|
||||
return json.dumps(log_obj, default=str)
|
||||
|
||||
def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(record.created))
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""Log all HTTP requests with timing and status codes."""
|
||||
|
||||
@@ -54,6 +17,7 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
start_time = time.time()
|
||||
client_host = request.client.host if request.client else "unknown"
|
||||
|
||||
# Log the incoming request
|
||||
logger.info(
|
||||
"→ Request: %s %s (client: %s)",
|
||||
request.method,
|
||||
@@ -65,6 +29,7 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
response = await call_next(request)
|
||||
duration = time.time() - start_time
|
||||
|
||||
# Log the response
|
||||
logger.info(
|
||||
"← Response: %s %s → %d (%dms)",
|
||||
request.method,
|
||||
@@ -104,13 +69,15 @@ class ExceptionLoggingMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
|
||||
def configure_logging(level: int = logging.INFO) -> None:
|
||||
"""Configure structured JSON logging for the application."""
|
||||
formatter = JSONFormatter()
|
||||
"""Configure structured logging for the application."""
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
# Console handler
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.addFilter(CorrelationIdFilter())
|
||||
|
||||
# Configure root logger
|
||||
root_logger = logging.getLogger()
|
||||
|
||||
+10
-45
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -6,40 +7,30 @@ from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from src.api.auth import router as auth_router
|
||||
from src.api.dashboard import router as dashboard_router
|
||||
from src.api.events import router as events_router
|
||||
from src.api.git_repositories import router as git_repositories_router
|
||||
from src.api.health import router as health_router
|
||||
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_definitions import router as tool_definitions_router
|
||||
from src.api.tool_configs import router as tool_configs_router
|
||||
from src.api.tool_instances import router as tool_instances_router
|
||||
from src.api.tool_instances import sessions_router
|
||||
from src.api.tool_types import router as tool_types_router
|
||||
from src.api.notifications import router as notifications_router
|
||||
from src.api.user_config import router as user_config_router
|
||||
from src.api.users import router as users_router
|
||||
from src.api.workspace_files import router as workspace_files_router
|
||||
from src.api.workspace_git import router as workspace_git_router
|
||||
from src.api.workspace_instances import router as workspace_instances_router
|
||||
from src.api.workspaces import all_workspaces_router, router as workspaces_router
|
||||
from src.config import Settings
|
||||
from src.models.notification import Notification # noqa: F401 – Alembic model discovery
|
||||
from src.models.terminal_session import TerminalSessionModel # noqa: F401 – Alembic model discovery
|
||||
from src.database import init_database
|
||||
from src.database import SessionLocal, init_database
|
||||
from src.logging_config import (
|
||||
ExceptionLoggingMiddleware,
|
||||
RequestLoggingMiddleware,
|
||||
configure_logging,
|
||||
)
|
||||
from src.services.correlation import CorrelationIdMiddleware
|
||||
from src.services.event_bus import InstanceEventBus
|
||||
from src.services.health_monitor import HealthMonitor
|
||||
from src.seeds.builtin_tool_types import seed_builtin_tool_types
|
||||
|
||||
# Configure logging early
|
||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
@@ -64,7 +55,6 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.add_middleware(CorrelationIdMiddleware)
|
||||
app.add_middleware(RequestLoggingMiddleware)
|
||||
app.add_middleware(ExceptionLoggingMiddleware)
|
||||
|
||||
@@ -77,9 +67,7 @@ def _sanitize_validation_errors(errors):
|
||||
"type": error.get("type"),
|
||||
"loc": error.get("loc"),
|
||||
"msg": error.get("msg"),
|
||||
"input": str(error.get("input"))
|
||||
if error.get("input") is not None
|
||||
else None,
|
||||
"input": str(error.get("input")) if error.get("input") is not None else None,
|
||||
}
|
||||
# Convert ctx to safe format
|
||||
ctx = error.get("ctx")
|
||||
@@ -114,11 +102,6 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
||||
)
|
||||
|
||||
|
||||
# Global services
|
||||
_event_bus = InstanceEventBus()
|
||||
_health_monitor = HealthMonitor(_event_bus)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
logger.info("Starting up Headquarter API...")
|
||||
@@ -128,24 +111,12 @@ async def on_startup():
|
||||
if not db_ready:
|
||||
logger.error("Database initialization failed. Shutting down.")
|
||||
import sys
|
||||
|
||||
sys.exit(1)
|
||||
|
||||
# Start background health monitor
|
||||
_health_monitor.start()
|
||||
logger.info("Health monitor started")
|
||||
|
||||
# Seed built-in data
|
||||
await seed_builtin_tool_types()
|
||||
logger.info("Startup complete.")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def on_shutdown():
|
||||
logger.info("Shutting down Headquarter API...")
|
||||
_health_monitor.stop()
|
||||
logger.info("Health monitor stopped")
|
||||
logger.info("Shutdown complete.")
|
||||
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(dashboard_router)
|
||||
@@ -155,17 +126,11 @@ 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(tool_definitions_router)
|
||||
app.include_router(config_folders_router)
|
||||
app.include_router(config_profiles_router)
|
||||
app.include_router(tool_instances_router)
|
||||
app.include_router(tool_configs_router)
|
||||
app.include_router(sessions_router)
|
||||
app.include_router(instance_proxy_router)
|
||||
app.include_router(terminal_router)
|
||||
app.include_router(events_router)
|
||||
app.include_router(notifications_router)
|
||||
app.include_router(all_workspaces_router)
|
||||
app.include_router(workspaces_router)
|
||||
app.include_router(workspace_files_router)
|
||||
app.include_router(workspace_git_router)
|
||||
app.include_router(workspace_instances_router)
|
||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
from src.models.base import Base
|
||||
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
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.health_check import HealthCheck
|
||||
from src.models.instance_event import InstanceEvent
|
||||
from src.models.notification import Notification
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.terminal_session import TerminalSessionModel
|
||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
@@ -15,16 +13,13 @@ from src.models.user_config import UserConfig
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"ConfigFolder",
|
||||
"ConfigInclude",
|
||||
"ConfigMount",
|
||||
"ConfigProfile",
|
||||
"ConfigProfileInclude",
|
||||
"GitRepository",
|
||||
"HealthCheck",
|
||||
"InstanceEvent",
|
||||
"Notification",
|
||||
"Project",
|
||||
"SSHKey",
|
||||
"TerminalSessionModel",
|
||||
"ToolDefinitionManifest",
|
||||
"ToolInstance",
|
||||
"ToolType",
|
||||
"User",
|
||||
|
||||
@@ -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()
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -1,13 +1,15 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean
|
||||
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
|
||||
@@ -15,63 +17,43 @@ if TYPE_CHECKING:
|
||||
|
||||
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
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
project_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
env_vars: Mapped[dict] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
) # {"VAR_NAME": "value", ...}
|
||||
runtime_hints: Mapped[dict] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
) # {"start_command": "...", "working_dir": "...", ...}
|
||||
mounts: Mapped[list] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
) # [{"target": "/path", "mode": "rw", "files": {"rel/path": "content"}}, ...]
|
||||
files: Mapped[dict] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
) # {"rel/path": "content", ...}
|
||||
git_mounts: Mapped[list] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
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["ConfigProfileInclude"]] = relationship(
|
||||
"ConfigProfileInclude",
|
||||
foreign_keys="ConfigProfileInclude.profile_id",
|
||||
order_by="ConfigProfileInclude.order_index",
|
||||
includes: Mapped[list["ConfigInclude"]] = relationship(
|
||||
"ConfigInclude",
|
||||
primaryjoin="ConfigProfile.id == ConfigInclude.profile_id",
|
||||
back_populates="profile",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ConfigInclude.order_index",
|
||||
)
|
||||
|
||||
|
||||
class ConfigProfileInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "config_profile_includes"
|
||||
|
||||
profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
included_profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
profile: Mapped["ConfigProfile"] = relationship(
|
||||
"ConfigProfile",
|
||||
foreign_keys=[profile_id],
|
||||
back_populates="includes",
|
||||
)
|
||||
included_profile: Mapped["ConfigProfile"] = relationship(
|
||||
"ConfigProfile",
|
||||
foreign_keys=[included_profile_id],
|
||||
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,7 +10,6 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
@@ -19,15 +18,11 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
path: Mapped[str] = mapped_column(String(1024))
|
||||
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=True)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=False)
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False)
|
||||
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
ssh_key_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(), ForeignKey("ssh_keys.id"), nullable=True
|
||||
)
|
||||
|
||||
project: Mapped["Project"] = relationship(back_populates="repositories")
|
||||
owner: Mapped["User"] = relationship()
|
||||
ssh_key: Mapped["SSHKey | None"] = relationship()
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import 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.project import Project
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
class ToolConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "tool_configs"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(), ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
tool_type_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(), ForeignKey("tool_types.id"), nullable=False
|
||||
)
|
||||
project_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(), ForeignKey("projects.id"), nullable=True
|
||||
)
|
||||
key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
config_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="env"
|
||||
) # "env" or "file"
|
||||
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()
|
||||
project: Mapped["Project | None"] = relationship()
|
||||
@@ -2,7 +2,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy import Uuid as UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -14,7 +14,6 @@ if TYPE_CHECKING:
|
||||
from src.models.project import Project
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.models.workspace import Workspace
|
||||
|
||||
|
||||
class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
@@ -34,40 +33,42 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(), ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
|
||||
container_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
container_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
compose_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
public_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
tunnel_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="pending"
|
||||
)
|
||||
container_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True
|
||||
)
|
||||
container_name: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True
|
||||
)
|
||||
compose_path: Mapped[str | None] = mapped_column(
|
||||
String(1024), nullable=True
|
||||
)
|
||||
url: Mapped[str | None] = mapped_column(
|
||||
String(1024), nullable=True
|
||||
)
|
||||
public_url: Mapped[str | None] = mapped_column(
|
||||
String(1024), nullable=True
|
||||
)
|
||||
tunnel_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True
|
||||
)
|
||||
port: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True
|
||||
)
|
||||
last_started_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
manifest_compiled_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
image_tag: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
probe_result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
clone_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="mount")
|
||||
branch: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, default="main"
|
||||
)
|
||||
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
selected_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
ssh_key_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||
workspace_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(), ForeignKey("workspaces.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
tool_type: Mapped["ToolType"] = relationship()
|
||||
workspace: Mapped["Workspace | None"] = relationship()
|
||||
repository: Mapped["GitRepository"] = relationship()
|
||||
project: Mapped["Project"] = relationship()
|
||||
owner: Mapped["User"] = relationship()
|
||||
selected_config_profile: Mapped["ConfigProfile | None"] = relationship()
|
||||
selected_profile: Mapped["ConfigProfile | None"] = relationship()
|
||||
|
||||
@@ -8,7 +8,6 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
@@ -19,36 +18,23 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
||||
interface_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="web"
|
||||
)
|
||||
requires_port: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
default_port: Mapped[int] = mapped_column(nullable=False)
|
||||
definition_type: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="legacy"
|
||||
) # "legacy" | "manifest"
|
||||
manifest_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(),
|
||||
ForeignKey("tool_definition_manifests.id"),
|
||||
nullable=True,
|
||||
)
|
||||
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)
|
||||
startup_command: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
required_variables: Mapped[list[str]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
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(
|
||||
UUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
manifest: Mapped["ToolDefinitionManifest | None"] = relationship(
|
||||
foreign_keys=[manifest_id],
|
||||
)
|
||||
created_by: Mapped["User | None"] = relationship()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic request/response schemas."""
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Config folder request/response schemas."""
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ConfigFolderCreate(BaseModel):
|
||||
name: str = Field(description="Folder name")
|
||||
description: str | None = Field(default=None, description="Optional description")
|
||||
mount_path: str = Field(description="Mount path in container")
|
||||
files: dict[str, str] | None = Field(
|
||||
default=None, description="Files as {path: content}"
|
||||
)
|
||||
is_active: bool = Field(default=True, description="Whether folder is active")
|
||||
|
||||
|
||||
class ConfigFolderUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
mount_path: str | None = None
|
||||
files: dict[str, str] | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class ProjectOverrideCreate(BaseModel):
|
||||
project_id: str = Field(description="Project ID to override for")
|
||||
mount_path: str | None = Field(default=None, description="Override mount path")
|
||||
files: dict[str, str] | None = Field(
|
||||
default=None, description="Override files"
|
||||
)
|
||||
is_active: bool | None = Field(default=None, description="Override active state")
|
||||
|
||||
|
||||
class ConfigFolderResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
description: str | None
|
||||
mount_path: str
|
||||
files: dict[str, str] | None
|
||||
is_active: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Config profile request/response schemas."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
MAX_MOUNT_PATH_LENGTH = 1024
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Git repository request/response schemas."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class GitRepositoryCreate(BaseModel):
|
||||
name: str
|
||||
remote_url: str | None = None
|
||||
force_original_url: bool = False
|
||||
|
||||
|
||||
class URLParseRequest(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class URLParseResponse(BaseModel):
|
||||
original_url: str
|
||||
base_url: str | None
|
||||
is_valid_clone_url: bool
|
||||
needs_parsing: bool
|
||||
host: str | None
|
||||
message: str
|
||||
error_code: str | None
|
||||
|
||||
|
||||
class GitRepositoryResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
path: str
|
||||
project_id: uuid.UUID
|
||||
owner_id: uuid.UUID
|
||||
is_mirror: bool
|
||||
remote_url: str | None
|
||||
last_push: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FileListResponse(BaseModel):
|
||||
path: str
|
||||
branch: str
|
||||
entries: list[dict]
|
||||
|
||||
|
||||
class FileContentResponse(BaseModel):
|
||||
path: str
|
||||
branch: str
|
||||
content: str
|
||||
size: int
|
||||
encoding: str
|
||||
language: str | None
|
||||
is_binary: bool
|
||||
last_commit: dict | None
|
||||
|
||||
|
||||
class BranchesResponse(BaseModel):
|
||||
branches: list[dict]
|
||||
default_branch: str
|
||||
|
||||
|
||||
class FileUpdateRequest(BaseModel):
|
||||
path: str
|
||||
branch: str
|
||||
content: str
|
||||
commit_message: str
|
||||
|
||||
|
||||
class FileUpdateResponse(BaseModel):
|
||||
commit_hash: str
|
||||
message: str
|
||||
branch: str
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
branch: str
|
||||
modified: list[str]
|
||||
added: list[str]
|
||||
deleted: list[str]
|
||||
untracked: list[str]
|
||||
renamed: list[str]
|
||||
ahead: int
|
||||
behind: int
|
||||
|
||||
|
||||
class BranchCreateRequest(BaseModel):
|
||||
name: str
|
||||
base_branch: str = "HEAD"
|
||||
|
||||
|
||||
class CheckoutRequest(BaseModel):
|
||||
branch: str
|
||||
|
||||
|
||||
class CommitRequest(BaseModel):
|
||||
message: str
|
||||
files: list[str] | None = None
|
||||
|
||||
|
||||
class CommitResponse(BaseModel):
|
||||
commit_hash: str
|
||||
message: str
|
||||
|
||||
|
||||
class FetchResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class PullResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class PushResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class MergeRequest(BaseModel):
|
||||
source_branch: str
|
||||
target_branch: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class MergeResponse(BaseModel):
|
||||
commit_hash: str
|
||||
message: str
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Health check response schemas."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DatabaseHealth(BaseModel):
|
||||
"""Database health check result."""
|
||||
|
||||
status: str = Field(description="Database health status", examples=["healthy"])
|
||||
response_time_ms: float = Field(
|
||||
description="Query response time in milliseconds", examples=[5.2]
|
||||
)
|
||||
|
||||
|
||||
class DiskHealth(BaseModel):
|
||||
"""Disk space health check result."""
|
||||
|
||||
status: str = Field(description="Disk health status", examples=["healthy"])
|
||||
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
|
||||
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
|
||||
|
||||
|
||||
class HealthChecks(BaseModel):
|
||||
"""Individual health checks."""
|
||||
|
||||
database: DatabaseHealth | None = None
|
||||
disk: DiskHealth | None = None
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Overall health check response."""
|
||||
|
||||
status: str = Field(description="Overall health status", examples=["healthy"])
|
||||
timestamp: str = Field(
|
||||
description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"]
|
||||
)
|
||||
version: str = Field(description="API version", examples=["0.1.0"])
|
||||
checks: HealthChecks = Field(description="Individual health checks")
|
||||
uptime_seconds: float = Field(
|
||||
description="Server uptime in seconds", examples=[3600.0]
|
||||
)
|
||||
|
||||
|
||||
class DatabaseHealthResponse(BaseModel):
|
||||
"""Database-specific health check response."""
|
||||
|
||||
status: str = Field(description="Database health status", examples=["healthy"])
|
||||
response_time_ms: float = Field(
|
||||
description="Query response time in milliseconds", examples=[5.2]
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Project request/response schemas."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str | None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class SetDefaultSSHKeyRequest(BaseModel):
|
||||
ssh_key_id: str
|
||||
@@ -0,0 +1,16 @@
|
||||
"""SSH key request/response schemas."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SSHKeyCreate(BaseModel):
|
||||
name: str
|
||||
public_key: str
|
||||
|
||||
|
||||
class SSHKeyResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
public_key: str
|
||||
fingerprint: str
|
||||
created_at: str
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Tool config request/response schemas."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ToolConfigCreate(BaseModel):
|
||||
tool_type_id: str = Field(description="UUID of the tool type")
|
||||
key: str = Field(description="Configuration key")
|
||||
value: str = Field(description="Configuration value")
|
||||
config_type: str = Field(default="env", description="Config type: env or file")
|
||||
file_path: str | None = Field(default=None, description="File path for file configs")
|
||||
port_override: int | None = Field(default=None, description="Port override")
|
||||
start_command: str | None = Field(default=None, description="Start command override")
|
||||
working_directory: str | None = Field(default=None, description="Working directory")
|
||||
environment_variables: dict[str, str] | None = Field(
|
||||
default=None, description="Additional environment variables"
|
||||
)
|
||||
volumes: list[dict] | None = Field(default=None, description="Volume mounts")
|
||||
|
||||
|
||||
class ToolConfigUpdate(BaseModel):
|
||||
value: str | None = None
|
||||
config_type: str | None = None
|
||||
file_path: str | None = None
|
||||
port_override: int | None = None
|
||||
start_command: str | None = None
|
||||
working_directory: str | None = None
|
||||
environment_variables: dict[str, str] | None = None
|
||||
volumes: list[dict] | None = None
|
||||
|
||||
|
||||
class ToolConfigResponse(BaseModel):
|
||||
id: str
|
||||
tool_type_id: str
|
||||
user_id: str
|
||||
project_id: str | None
|
||||
key: str
|
||||
value: str
|
||||
config_type: str
|
||||
file_path: str | None
|
||||
port_override: int | None
|
||||
start_command: str | None
|
||||
working_directory: str | None
|
||||
environment_variables: dict[str, str] | None
|
||||
volumes: list[dict] | None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Tool instance request/response schemas."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateInstanceRequest(BaseModel):
|
||||
"""Request body for creating a tool instance."""
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
class SessionItemResponse(BaseModel):
|
||||
"""Lightweight session summary for sidebar and dashboard."""
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
id: str = Field(description="Session (tool instance) ID")
|
||||
display_name: str = Field(description="Display name of the session")
|
||||
tool_type_name: str = Field(description="Name of the tool type")
|
||||
tool_icon: str | None = Field(default=None, description="Icon URL for the tool type")
|
||||
tool_type_interfaces: list[str] = Field(default_factory=list, description="Supported interfaces")
|
||||
repository_name: str = Field(description="Name of the repository")
|
||||
repository_id: str = Field(description="Repository ID")
|
||||
project_name: str = Field(description="Name of the project")
|
||||
project_id: str = Field(description="Project ID")
|
||||
status: str = Field(description="Current status")
|
||||
url: str | None = Field(default=None, description="Access URL")
|
||||
|
||||
|
||||
class SessionListResponse(BaseModel):
|
||||
"""Response wrapping a list of session summaries."""
|
||||
|
||||
sessions: list[SessionItemResponse]
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Tool type request/response schemas."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
|
||||
|
||||
class ToolTypeCreate(BaseModel):
|
||||
name: str
|
||||
display_name: str
|
||||
description: str | None = None
|
||||
default_port: int
|
||||
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 | 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:
|
||||
raise ValueError(f"Invalid YAML: {e}")
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("Compose template must be a YAML mapping")
|
||||
if "services" not in parsed:
|
||||
raise ValueError("Compose template must contain 'services' key")
|
||||
if not parsed["services"]:
|
||||
raise ValueError("Compose template must define at least one service")
|
||||
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:
|
||||
if v <= 0 or v > 65535:
|
||||
raise ValueError("Port must be between 1 and 65535")
|
||||
data = info.data
|
||||
if data.get("definition_type") != "compose":
|
||||
return v
|
||||
template = data.get("compose_template")
|
||||
if not template:
|
||||
return v
|
||||
try:
|
||||
parsed = yaml.safe_load(template)
|
||||
except yaml.YAMLError:
|
||||
return v
|
||||
port_str = str(v)
|
||||
port_exposed = False
|
||||
if isinstance(parsed, dict) and "services" in parsed:
|
||||
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 == v:
|
||||
port_exposed = True
|
||||
break
|
||||
if port_exposed:
|
||||
break
|
||||
if not port_exposed:
|
||||
raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.")
|
||||
return v
|
||||
|
||||
@field_validator("required_variables")
|
||||
@classmethod
|
||||
def validate_required_variables(cls, v: list[str], info) -> list[str]:
|
||||
if not v:
|
||||
return v
|
||||
data = info.data
|
||||
if data.get("definition_type") != "compose":
|
||||
return v
|
||||
template = data.get("compose_template")
|
||||
if not template:
|
||||
return v
|
||||
for var in v:
|
||||
placeholder = f"{{{{{var}}}}}"
|
||||
if placeholder not in template:
|
||||
raise ValueError(f"Required variable '{var}' not found in compose template")
|
||||
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, 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:
|
||||
raise ValueError(f"Invalid YAML: {e}")
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("Compose template must be a YAML mapping")
|
||||
if "services" not in parsed:
|
||||
raise ValueError("Compose template must contain 'services' key")
|
||||
if not parsed["services"]:
|
||||
raise ValueError("Compose template must define at least one service")
|
||||
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)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
display_name: str
|
||||
description: str | None
|
||||
category: str
|
||||
interfaces: list[str]
|
||||
default_port: int
|
||||
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
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ToolTypeValidateRequest(BaseModel):
|
||||
definition_type: str
|
||||
compose_template: str | None = None
|
||||
dockerfile_template: str | None = None
|
||||
@@ -0,0 +1,19 @@
|
||||
"""User request/response schemas."""
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
name: str
|
||||
avatar_url: str | None
|
||||
|
||||
|
||||
class UserProfileUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
email: str | None = None
|
||||
@@ -0,0 +1,21 @@
|
||||
"""User config request/response schemas."""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class UserConfigResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
default_editor: str | None = None
|
||||
theme: str = "system"
|
||||
git_user_name: str | None = None
|
||||
git_user_email: str | None = None
|
||||
last_session_id: str | None = None
|
||||
|
||||
|
||||
class UserConfigUpdate(BaseModel):
|
||||
default_editor: str | None = None
|
||||
theme: str | None = None
|
||||
git_user_name: str | None = None
|
||||
git_user_email: str | None = None
|
||||
last_session_id: str | None = None
|
||||
@@ -0,0 +1,161 @@
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.database import SessionLocal
|
||||
from src.models.tool_type import ToolType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _table_exists(session, table_name: str) -> bool:
|
||||
"""Check if a table exists in the database."""
|
||||
from sqlalchemy import text
|
||||
|
||||
try:
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = :table_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name},
|
||||
)
|
||||
return result.scalar() or False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def seed_builtin_tool_types():
|
||||
async with SessionLocal() as session:
|
||||
# Check if tool_types table exists before attempting to seed
|
||||
if not await _table_exists(session, "tool_types"):
|
||||
logger.warning(
|
||||
"tool_types table does not exist. Skipping seeding. "
|
||||
"Migrations may not have run yet."
|
||||
)
|
||||
return
|
||||
|
||||
builtin_types = [
|
||||
{
|
||||
"name": "code-server",
|
||||
"display_name": "VS Code Server",
|
||||
"description": "VS Code running in the browser via code-server",
|
||||
"category": "editor",
|
||||
"interfaces": ["web"],
|
||||
"compose_template": """version: "3.8"
|
||||
services:
|
||||
code-server:
|
||||
image: lscr.io/linuxserver/code-server:latest
|
||||
container_name: {{TOOL_NAME}}
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=Europe/London
|
||||
volumes:
|
||||
- {{REPO_PATH}}:/config/workspace
|
||||
ports:
|
||||
- "8443:8443"
|
||||
restart: unless-stopped""",
|
||||
"default_port": 8443,
|
||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||
},
|
||||
{
|
||||
"name": "jupyter-notebook",
|
||||
"display_name": "Jupyter Notebook",
|
||||
"description": "Jupyter Lab for interactive development",
|
||||
"category": "notebook",
|
||||
"interfaces": ["web"],
|
||||
"default_port": 8888,
|
||||
"compose_template": """version: "3.8"
|
||||
services:
|
||||
jupyter:
|
||||
image: jupyter/scipy-notebook:latest
|
||||
container_name: {{TOOL_NAME}}
|
||||
environment:
|
||||
- JUPYTER_ENABLE_LAB=yes
|
||||
volumes:
|
||||
- {{REPO_PATH}}:/home/jovyan/work
|
||||
ports:
|
||||
- "8888:8888"
|
||||
restart: unless-stopped""",
|
||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||
},
|
||||
{
|
||||
"name": "opencode",
|
||||
"display_name": "OpenCode",
|
||||
"description": "AI coding assistant - run opencode in terminal",
|
||||
"category": "ai-assistant",
|
||||
"interfaces": ["terminal"],
|
||||
"default_port": 3000,
|
||||
"compose_template": """version: "3.8"
|
||||
services:
|
||||
opencode:
|
||||
image: node:20-slim
|
||||
container_name: {{TOOL_NAME}}
|
||||
working_dir: /workspace
|
||||
environment:
|
||||
- HOME=/tmp
|
||||
volumes:
|
||||
- {{REPO_PATH}}:/workspace
|
||||
- opencode_home:/tmp
|
||||
ports:
|
||||
- "3000:3000"
|
||||
command: >
|
||||
sh -c "set -x &&
|
||||
apt-get update && apt-get install -y git ca-certificates &&
|
||||
echo 'Installing opencode...' &&
|
||||
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
|
||||
which opencode || echo 'ERROR: opencode not in PATH' &&
|
||||
npm bin -g &&
|
||||
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
|
||||
echo 'export PATH=\"$(npm bin -g):\\$PATH\"' >> /root/.bashrc &&
|
||||
echo 'cd /workspace' >> /root/.bashrc &&
|
||||
echo 'OpenCode installation complete' &&
|
||||
cd /workspace &&
|
||||
exec tail -f /dev/null"
|
||||
stdin_open: true
|
||||
tty: true
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
opencode_home:""",
|
||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||
},
|
||||
]
|
||||
|
||||
for tool_data in builtin_types:
|
||||
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
|
||||
if not existing:
|
||||
tool_type = ToolType(
|
||||
name=tool_data["name"],
|
||||
display_name=tool_data["display_name"],
|
||||
description=tool_data["description"],
|
||||
category=tool_data["category"],
|
||||
interfaces=tool_data["interfaces"],
|
||||
definition_type="compose",
|
||||
compose_template=tool_data["compose_template"],
|
||||
required_variables=tool_data["required_variables"],
|
||||
default_port=tool_data.get("default_port"),
|
||||
is_builtin=True,
|
||||
)
|
||||
session.add(tool_type)
|
||||
logger.info("Created built-in tool type: %s", tool_data["name"])
|
||||
else:
|
||||
# Update existing built-in tool types to reflect code changes
|
||||
existing.display_name = tool_data["display_name"]
|
||||
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")
|
||||
logger.info("Updated built-in tool type: %s", tool_data["name"])
|
||||
|
||||
await session.commit()
|
||||
logger.info("Built-in tool types seeded successfully.")
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Config profile business logic."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
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__)
|
||||
|
||||
MAX_INCLUDES_DEPTH = 10
|
||||
|
||||
|
||||
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 helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def check_duplicate_name(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
name: str,
|
||||
exclude_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Raise 409 if a profile with the given name already exists."""
|
||||
query = select(ConfigProfile).where(
|
||||
ConfigProfile.user_id == user_id,
|
||||
ConfigProfile.name == name,
|
||||
)
|
||||
if exclude_id:
|
||||
query = query.where(ConfigProfile.id != exclude_id)
|
||||
existing = await session.scalar(query)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"config profile with name '{name}' already exists",
|
||||
)
|
||||
|
||||
|
||||
def profile_to_dict(profile: ConfigProfile) -> dict:
|
||||
"""Serialize a ConfigProfile to a dict."""
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Include helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def check_duplicate_include(
|
||||
session: AsyncSession,
|
||||
profile_id: uuid.UUID,
|
||||
included_profile_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Raise 409 if the include already exists."""
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
def include_to_dict(inc: ConfigInclude, included_name: str | None) -> dict:
|
||||
"""Serialize a ConfigInclude to a dict."""
|
||||
return {
|
||||
"id": str(inc.id),
|
||||
"profile_id": str(inc.profile_id),
|
||||
"included_profile_id": str(inc.included_profile_id),
|
||||
"included_profile_name": included_name,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mount helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def check_duplicate_mount_path(
|
||||
session: AsyncSession,
|
||||
profile_id: uuid.UUID,
|
||||
target_path: str,
|
||||
exclude_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Raise 409 if a mount with the given path already exists."""
|
||||
query = select(ConfigMount).where(
|
||||
ConfigMount.profile_id == profile_id,
|
||||
ConfigMount.target_path == target_path,
|
||||
)
|
||||
if exclude_id:
|
||||
query = query.where(ConfigMount.id != exclude_id)
|
||||
existing = await session.scalar(query)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"mount with path '{target_path}' already exists",
|
||||
)
|
||||
|
||||
|
||||
def mount_to_dict(mount: ConfigMount) -> dict:
|
||||
"""Serialize a ConfigMount to a dict."""
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default profile helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_or_create_user_config(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
) -> UserConfig:
|
||||
"""Get existing user config or create a new one."""
|
||||
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)
|
||||
return user_config
|
||||
|
||||
|
||||
async def validate_default_profiles(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
default_profiles: dict[str, str],
|
||||
) -> None:
|
||||
"""Validate that all profile IDs in default_profiles belong to the user."""
|
||||
for tool_type_id, profile_id_str in 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")
|
||||
|
||||
|
||||
async def get_default_profiles(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
) -> dict:
|
||||
"""Get default profiles for a user."""
|
||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
||||
user_config = result.scalar_one_or_none()
|
||||
return {"default_profiles": user_config.default_profiles if user_config else {}}
|
||||
|
||||
|
||||
async def set_default_profiles(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
default_profiles: dict[str, str],
|
||||
) -> dict:
|
||||
"""Set default profiles for a user."""
|
||||
user_config = await get_or_create_user_config(session, user_id)
|
||||
await validate_default_profiles(session, user_id, default_profiles)
|
||||
user_config.config = {**user_config.config, "default_profiles": default_profiles}
|
||||
await session.commit()
|
||||
await session.refresh(user_config)
|
||||
return {"default_profiles": user_config.default_profiles}
|
||||
|
||||
|
||||
async def get_default_profile_for_tool_type(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
tool_type_id: str,
|
||||
) -> 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()
|
||||
profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None
|
||||
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Include list helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def list_includes_for_profile(
|
||||
session: AsyncSession,
|
||||
profile_id: uuid.UUID,
|
||||
) -> dict:
|
||||
"""List all includes for a profile."""
|
||||
result = await session.execute(
|
||||
select(ConfigInclude)
|
||||
.where(ConfigInclude.profile_id == profile_id)
|
||||
.order_by(ConfigInclude.order_index)
|
||||
)
|
||||
includes_data = []
|
||||
for inc in result.scalars().all():
|
||||
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
|
||||
includes_data.append(include_to_dict(inc, included_profile.name if included_profile else None))
|
||||
return {"includes": includes_data}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mount list helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def list_mounts_for_profile(
|
||||
session: AsyncSession,
|
||||
profile_id: uuid.UUID,
|
||||
) -> dict:
|
||||
"""List all mounts for a profile."""
|
||||
result = await session.execute(
|
||||
select(ConfigMount)
|
||||
.where(ConfigMount.profile_id == profile_id)
|
||||
.order_by(ConfigMount.order_index)
|
||||
)
|
||||
return {"mounts": [mount_to_dict(m) for m in result.scalars().all()]}
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Docker services for container and tunnel management."""
|
||||
|
||||
from .compose import (
|
||||
ensure_instance_directory,
|
||||
execute_compose_command,
|
||||
render_compose_template,
|
||||
write_compose_file,
|
||||
write_env_file,
|
||||
)
|
||||
from .config_staging import write_config_files, write_config_folder_files
|
||||
from .container import (
|
||||
connect_container_to_network,
|
||||
find_free_port,
|
||||
get_container_id,
|
||||
get_container_logs,
|
||||
get_container_name,
|
||||
get_container_status,
|
||||
)
|
||||
from .tunnel import (
|
||||
check_tunnel_health,
|
||||
recreate_tunnel,
|
||||
start_cloudflared_tunnel,
|
||||
stop_cloudflared_tunnel,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"render_compose_template",
|
||||
"ensure_instance_directory",
|
||||
"write_compose_file",
|
||||
"write_env_file",
|
||||
"execute_compose_command",
|
||||
"write_config_files",
|
||||
"write_config_folder_files",
|
||||
"get_container_id",
|
||||
"get_container_name",
|
||||
"connect_container_to_network",
|
||||
"get_container_status",
|
||||
"get_container_logs",
|
||||
"find_free_port",
|
||||
"start_cloudflared_tunnel",
|
||||
"stop_cloudflared_tunnel",
|
||||
"recreate_tunnel",
|
||||
"check_tunnel_health",
|
||||
]
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Docker Compose file generation and command execution."""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.config_profile import ConfigProfile
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.services.profile_resolver import resolve_profile
|
||||
|
||||
|
||||
def _sanitize_name(name: str) -> str:
|
||||
"""Sanitize a string for use in Docker/container names."""
|
||||
sanitized = re.sub(r"[^a-z0-9-]", "-", name.lower())
|
||||
sanitized = re.sub(r"-+", "-", sanitized)
|
||||
return sanitized.strip("-")
|
||||
|
||||
|
||||
async def _generate_instance_name(
|
||||
session: AsyncSession,
|
||||
project_name: str,
|
||||
tool_type_name: str,
|
||||
) -> str:
|
||||
"""Generate a unique instance name: project-tool-NUM."""
|
||||
base = f"{_sanitize_name(project_name)}-{_sanitize_name(tool_type_name)}"
|
||||
base = base.strip("-") or "instance"
|
||||
result = await session.execute(
|
||||
select(ToolInstance.name).where(ToolInstance.name.like(f"{base}-%"))
|
||||
)
|
||||
names = result.scalars().all()
|
||||
max_num = 0
|
||||
for name in names:
|
||||
parts = name.rsplit("-", 1)
|
||||
if len(parts) == 2 and parts[0] == base and parts[1].isdigit():
|
||||
max_num = max(max_num, int(parts[1]))
|
||||
return f"{base}-{max_num + 1:03d}"
|
||||
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
for service_name, service_config in compose_data["services"].items():
|
||||
if port_override and "ports" in service_config:
|
||||
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
|
||||
|
||||
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
||||
|
||||
|
||||
async def _apply_resolved_profile(
|
||||
profile: ConfigProfile,
|
||||
instance_dir: str,
|
||||
env_vars: dict[str, str],
|
||||
port_override: int | None,
|
||||
start_command: str | None,
|
||||
working_directory: str | None,
|
||||
extra_volumes: list[dict],
|
||||
) -> tuple[dict[str, str], int | None, str | None, str | None, list[dict]]:
|
||||
"""Resolve a profile and apply its output to instance configuration."""
|
||||
resolved = resolve_profile(profile)
|
||||
|
||||
if resolved.environment_variables:
|
||||
env_vars.update(resolved.environment_variables)
|
||||
|
||||
if resolved.runtime_hints.start_command is not None:
|
||||
start_command = resolved.runtime_hints.start_command
|
||||
if resolved.runtime_hints.working_directory is not None:
|
||||
working_directory = resolved.runtime_hints.working_directory
|
||||
if resolved.runtime_hints.port is not None:
|
||||
port_override = resolved.runtime_hints.port
|
||||
|
||||
for target_path, mount in resolved.mounts.items():
|
||||
safe_name = target_path.strip("/").replace("/", "_")
|
||||
mount_dir = Path(instance_dir) / "mounts" / safe_name
|
||||
mount_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for rel_path, content in mount.files.items():
|
||||
file_path = mount_dir / rel_path
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content)
|
||||
|
||||
extra_volumes.append({
|
||||
"source": str(mount_dir),
|
||||
"target": target_path,
|
||||
"type": mount.mode,
|
||||
})
|
||||
|
||||
return env_vars, port_override, start_command, working_directory, extra_volumes
|
||||
|
||||
|
||||
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
||||
"""Render a Docker Compose template with variable substitution.
|
||||
|
||||
Args:
|
||||
template: The compose template string
|
||||
variables: Dictionary of variable names to values
|
||||
|
||||
Returns:
|
||||
Rendered compose file content
|
||||
"""
|
||||
result = template
|
||||
for key, value in variables.items():
|
||||
placeholder = f"{{{{{key}}}}}"
|
||||
result = result.replace(placeholder, str(value))
|
||||
return result
|
||||
|
||||
|
||||
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
|
||||
"""Create and return the instance directory path.
|
||||
|
||||
Args:
|
||||
instance_id: Unique instance identifier
|
||||
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
|
||||
|
||||
Returns:
|
||||
Absolute path to instance directory
|
||||
"""
|
||||
if base_path is None:
|
||||
from src.config import Settings
|
||||
base_path = Settings().instance_base_path
|
||||
instance_dir = Path(base_path) / instance_id
|
||||
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||
return str(instance_dir.absolute())
|
||||
|
||||
|
||||
def write_compose_file(instance_dir: str, content: str) -> str:
|
||||
"""Write the rendered compose file to the instance directory.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
content: Rendered compose content
|
||||
|
||||
Returns:
|
||||
Path to the compose file
|
||||
"""
|
||||
compose_path = Path(instance_dir) / "docker-compose.yml"
|
||||
compose_path.write_text(content)
|
||||
return str(compose_path)
|
||||
|
||||
|
||||
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
|
||||
"""Write environment variables to a .env file.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
env_vars: Dictionary of env var names to values
|
||||
|
||||
Returns:
|
||||
Path to the env file
|
||||
"""
|
||||
env_path = Path(instance_dir) / ".env"
|
||||
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
|
||||
env_path.write_text("\n".join(lines) + "\n")
|
||||
return str(env_path)
|
||||
|
||||
|
||||
def execute_compose_command(
|
||||
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||
) -> tuple[int, str, str]:
|
||||
"""Execute a docker compose command.
|
||||
|
||||
Args:
|
||||
compose_path: Path to docker-compose.yml
|
||||
action: The compose action (up, down, start, stop, restart)
|
||||
timeout: Command timeout in seconds
|
||||
env_file: Optional path to .env file for environment variables
|
||||
|
||||
Returns:
|
||||
Tuple of (returncode, stdout, stderr)
|
||||
"""
|
||||
instance_dir = Path(compose_path).parent
|
||||
|
||||
cmd = ["docker", "compose", "-f", compose_path]
|
||||
|
||||
if env_file:
|
||||
cmd.extend(["--env-file", env_file])
|
||||
|
||||
if action == "up":
|
||||
cmd.extend(["up", "-d"])
|
||||
elif action == "down":
|
||||
cmd.extend(["down", "-v"])
|
||||
elif action in ("start", "stop", "restart"):
|
||||
cmd.append(action)
|
||||
else:
|
||||
raise ValueError(f"Unknown compose action: {action}")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(instance_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Config folder file staging for Docker instances."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||
"""Write config files to the instance directory.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
files: Dictionary of file paths (relative to instance dir) to content
|
||||
"""
|
||||
instance_path = Path(instance_dir)
|
||||
for file_path, content in files.items():
|
||||
# Ensure the path is within the instance directory (security)
|
||||
full_path = instance_path / file_path
|
||||
try:
|
||||
full_path.resolve().relative_to(instance_path.resolve())
|
||||
except ValueError:
|
||||
raise ValueError(f"File path '{file_path}' escapes instance directory")
|
||||
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
|
||||
|
||||
def write_config_folder_files(instance_dir: str, folders: list, project_id: str | None = None) -> list[dict]:
|
||||
"""Write config folder files to the instance directory and return volume mounts.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
folders: List of ConfigFolder objects
|
||||
project_id: Optional project ID for applying overrides
|
||||
|
||||
Returns:
|
||||
List of volume mount dicts [{"source": "...", "target": "...", "type": "..."}]
|
||||
"""
|
||||
instance_path = Path(instance_dir)
|
||||
volume_mounts = []
|
||||
|
||||
for folder in folders:
|
||||
# Determine mount path (with project override if applicable)
|
||||
mount_path = folder.mount_path
|
||||
files = folder.files.copy()
|
||||
|
||||
if project_id and folder.project_overrides:
|
||||
override = folder.project_overrides.get(str(project_id))
|
||||
if override:
|
||||
if override.get("mount_path"):
|
||||
mount_path = override["mount_path"]
|
||||
if override.get("files"):
|
||||
files.update(override["files"])
|
||||
|
||||
# Write files to instance directory
|
||||
folder_dir = instance_path / "volumes" / folder.name
|
||||
folder_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for file_path, content in files.items():
|
||||
# Security: ensure path doesn't escape folder_dir
|
||||
full_path = folder_dir / file_path
|
||||
try:
|
||||
full_path.resolve().relative_to(folder_dir.resolve())
|
||||
except ValueError:
|
||||
logger.warning("Config folder file path escapes directory: %s", file_path)
|
||||
continue
|
||||
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
|
||||
# Add volume mount
|
||||
volume_mounts.append({
|
||||
"source": str(folder_dir),
|
||||
"target": mount_path,
|
||||
"type": "bind",
|
||||
})
|
||||
|
||||
return volume_mounts
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Docker container lifecycle and query operations."""
|
||||
|
||||
import socket
|
||||
import subprocess
|
||||
|
||||
|
||||
def get_container_id(instance_name: str) -> str | None:
|
||||
"""Get the container ID for a compose service.
|
||||
|
||||
Args:
|
||||
instance_name: The service name in compose
|
||||
|
||||
Returns:
|
||||
Container ID or None if not found
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip().split("\n")[0]
|
||||
return None
|
||||
|
||||
|
||||
def get_container_name(instance_name: str) -> str | None:
|
||||
"""Get the full container name for a compose service.
|
||||
|
||||
Args:
|
||||
instance_name: The service name in compose
|
||||
|
||||
Returns:
|
||||
Container name or None if not found
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip().split("\n")[0]
|
||||
return None
|
||||
|
||||
|
||||
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
|
||||
"""Connect a Docker container to an existing network.
|
||||
|
||||
Args:
|
||||
container_name: Name or ID of the container
|
||||
network_name: Name of the Docker network (default: backend)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "network", "connect", network_name, container_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def get_container_status(container_id: str) -> str:
|
||||
"""Get the status of a Docker container.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
|
||||
Returns:
|
||||
Container status string (running, exited, etc.)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.State.Status}}", container_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
||||
"""Get the logs of a Docker container.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
tail: Number of lines to return
|
||||
|
||||
Returns:
|
||||
Container logs
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "logs", "--tail", str(tail), container_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
return f"Failed to get logs: {result.stderr}"
|
||||
|
||||
|
||||
def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
||||
"""Find a free TCP port in the given range.
|
||||
|
||||
Args:
|
||||
start: Start of port range
|
||||
end: End of port range
|
||||
|
||||
Returns:
|
||||
Free port number
|
||||
"""
|
||||
for port in range(start, end):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
if s.connect_ex(("localhost", port)) != 0:
|
||||
return port
|
||||
|
||||
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Cloudflare tunnel management for Docker instances."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def start_cloudflared_tunnel(
|
||||
container_name: str, port: int, timeout: int = 30
|
||||
) -> dict[str, str]:
|
||||
"""Start a temporary Cloudflare tunnel for a container.
|
||||
|
||||
Uses 'cloudflared tunnel --url' to create a temporary tunnel
|
||||
with a random trycloudflare.com URL.
|
||||
|
||||
Args:
|
||||
container_name: Name of the Docker container to tunnel to
|
||||
port: Port number the container listens on
|
||||
timeout: Maximum seconds to wait for tunnel URL
|
||||
|
||||
Returns:
|
||||
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
||||
"""
|
||||
import select as sel
|
||||
|
||||
# First verify the container is accessible
|
||||
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
||||
for attempt in range(10):
|
||||
check = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
f"http://{container_name}:{port}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
|
||||
if check.returncode == 0:
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
|
||||
|
||||
# Run cloudflared in background, capture output
|
||||
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
||||
proc = subprocess.Popen(
|
||||
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Wait for the URL to appear in output
|
||||
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
||||
start_time = time.time()
|
||||
url = None
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
# Read available output
|
||||
readable, _, _ = sel.select([proc.stdout], [], [], 1.0)
|
||||
if readable:
|
||||
line = proc.stdout.readline()
|
||||
if line:
|
||||
match = url_pattern.search(line)
|
||||
if match:
|
||||
url = match.group(0)
|
||||
break
|
||||
|
||||
if not url:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
raise RuntimeError(
|
||||
f"Failed to get tunnel URL within {timeout}s. "
|
||||
f"cloudflared output may contain errors."
|
||||
)
|
||||
|
||||
return {"url": url, "pid": str(proc.pid)}
|
||||
|
||||
|
||||
def stop_cloudflared_tunnel(pid: str) -> None:
|
||||
"""Stop a cloudflared tunnel process.
|
||||
|
||||
Args:
|
||||
pid: Process ID of the cloudflared tunnel
|
||||
"""
|
||||
try:
|
||||
os.kill(int(pid), signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass # Already stopped
|
||||
|
||||
|
||||
def recreate_tunnel(
|
||||
container_name: str, port: int, old_pid: str | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Recreate a temporary Cloudflare tunnel.
|
||||
|
||||
Stops the old tunnel (if pid provided) and starts a new one.
|
||||
|
||||
Args:
|
||||
container_name: Name of the Docker container to tunnel to
|
||||
port: Port number the container listens on
|
||||
old_pid: Optional PID of the old tunnel process to stop
|
||||
|
||||
Returns:
|
||||
Dict with 'url' and 'pid' for the new tunnel
|
||||
"""
|
||||
if old_pid:
|
||||
stop_cloudflared_tunnel(old_pid)
|
||||
|
||||
return start_cloudflared_tunnel(container_name, port)
|
||||
|
||||
|
||||
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||
"""Check if a tunnel URL is healthy.
|
||||
|
||||
Args:
|
||||
url: The tunnel URL to check
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
Dict with 'healthy' (bool) and 'status_code' (int or None)
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"--max-time", str(timeout), url],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout + 5,
|
||||
)
|
||||
status_code = int(result.stdout.strip())
|
||||
return {
|
||||
"healthy": 200 <= status_code < 400,
|
||||
"status_code": status_code,
|
||||
}
|
||||
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
|
||||
return {
|
||||
"healthy": False,
|
||||
"status_code": None,
|
||||
"error": str(e),
|
||||
}
|
||||
@@ -6,9 +6,7 @@ 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]:
|
||||
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:
|
||||
@@ -20,18 +18,13 @@ def build_image(
|
||||
Returns:
|
||||
Tuple of (returncode, stdout, stderr)
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Defensive: normalise any CRLF that may have crept in from manifest DB
|
||||
# strings — Docker's legacy builder treats \r as a character after the
|
||||
# backslash, breaking RUN continuations and producing
|
||||
# "unknown instruction" errors.
|
||||
dockerfile = dockerfile.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
# Write Dockerfile
|
||||
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
||||
dockerfile_path.write_text(dockerfile, newline="\n")
|
||||
logger.debug("Wrote Dockerfile to %s (%d bytes)", dockerfile_path, len(dockerfile))
|
||||
dockerfile_path.write_text(dockerfile)
|
||||
logger.info("Wrote Dockerfile to %s", dockerfile_path)
|
||||
|
||||
# Write build context files
|
||||
if build_context:
|
||||
@@ -41,27 +34,19 @@ def build_image(
|
||||
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"
|
||||
)
|
||||
|
||||
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)
|
||||
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
full_path.write_text(normalized, newline="\n")
|
||||
logger.debug("Wrote build context file: %s", full_path)
|
||||
full_path.write_text(content)
|
||||
logger.info("Wrote build context file: %s", full_path)
|
||||
|
||||
# Build image
|
||||
logger.debug("Building Docker image with tag: %s", tag)
|
||||
logger.info("Building Docker image with tag: %s", tag)
|
||||
cmd = [
|
||||
"docker",
|
||||
"build",
|
||||
"-t",
|
||||
tag,
|
||||
"-f",
|
||||
str(dockerfile_path),
|
||||
"docker", "build",
|
||||
"-t", tag,
|
||||
"-f", str(dockerfile_path),
|
||||
instance_dir,
|
||||
]
|
||||
|
||||
@@ -72,7 +57,7 @@ def build_image(
|
||||
text=True,
|
||||
timeout=300, # 5 minute timeout for builds
|
||||
)
|
||||
logger.debug("Docker build completed: returncode=%d", result.returncode)
|
||||
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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Git services package."""
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Git control operations with repo validation."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.user import User
|
||||
from src.schemas.git_repository import (
|
||||
BranchCreateRequest,
|
||||
CheckoutRequest,
|
||||
CommitRequest,
|
||||
FetchResponse,
|
||||
MergeRequest,
|
||||
MergeResponse,
|
||||
PullResponse,
|
||||
PushResponse,
|
||||
StatusResponse,
|
||||
)
|
||||
from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate
|
||||
from src.utils.git_control import (
|
||||
checkout_branch,
|
||||
commit_changes,
|
||||
create_branch,
|
||||
delete_branch,
|
||||
fetch,
|
||||
get_status,
|
||||
merge,
|
||||
pull,
|
||||
push,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_status_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
) -> StatusResponse:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
result = get_status(repo.path)
|
||||
return StatusResponse(
|
||||
branch=result.branch,
|
||||
modified=result.modified,
|
||||
added=result.added,
|
||||
deleted=result.deleted,
|
||||
untracked=result.untracked,
|
||||
renamed=result.renamed,
|
||||
ahead=result.ahead,
|
||||
behind=result.behind,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def create_branch_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
data: BranchCreateRequest,
|
||||
) -> dict:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
create_branch(repo.path, data.name, data.base_branch)
|
||||
return {"message": f"Branch '{data.name}' created", "branch": data.name}
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def delete_branch_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
branch_name: str,
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
delete_branch(repo.path, branch_name, force)
|
||||
return {"message": f"Branch '{branch_name}' deleted"}
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def checkout_branch_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
data: CheckoutRequest,
|
||||
) -> dict:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
checkout_branch(repo.path, data.branch)
|
||||
return {"message": f"Checked out branch '{data.branch}'", "branch": data.branch}
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def commit_changes_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
data: CommitRequest,
|
||||
user: User,
|
||||
) -> dict:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
author_name = user.name or "Unknown"
|
||||
author_email = user.email or "unknown@example.com"
|
||||
try:
|
||||
commit_hash = commit_changes(
|
||||
repo_path=repo.path,
|
||||
message=data.message,
|
||||
author_name=author_name,
|
||||
author_email=author_email,
|
||||
files=data.files,
|
||||
)
|
||||
return {"commit_hash": commit_hash, "message": data.message}
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def fetch_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
) -> FetchResponse:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
fetch(repo.path)
|
||||
return FetchResponse(message="Fetched from remote")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def pull_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
branch: str | None = None,
|
||||
) -> PullResponse:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
pull(repo.path, branch)
|
||||
return PullResponse(message="Pulled from remote")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def push_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
branch: str | None = None,
|
||||
) -> PushResponse:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
push(repo.path, branch)
|
||||
return PushResponse(message="Pushed to remote")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def merge_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
data: MergeRequest,
|
||||
) -> MergeResponse:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
commit_hash = merge(
|
||||
repo_path=repo.path,
|
||||
source_branch=data.source_branch,
|
||||
target_branch=data.target_branch,
|
||||
message=data.message,
|
||||
)
|
||||
return MergeResponse(
|
||||
commit_hash=commit_hash,
|
||||
message=data.message or f"Merge {data.source_branch}",
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Git file operations with repo validation."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.user import User
|
||||
from src.schemas.git_repository import (
|
||||
FileContentResponse,
|
||||
FileListResponse,
|
||||
FileUpdateRequest,
|
||||
FileUpdateResponse,
|
||||
)
|
||||
from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate
|
||||
from src.utils.git_files import (
|
||||
commit_file,
|
||||
get_file_content,
|
||||
list_branches,
|
||||
list_tree,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def list_files(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
branch: str = "main",
|
||||
path: str = "",
|
||||
) -> FileListResponse:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
entries = list_tree(repo.path, branch=branch, path=path)
|
||||
return FileListResponse(
|
||||
path=path,
|
||||
branch=branch,
|
||||
entries=[
|
||||
{
|
||||
"name": e.name,
|
||||
"type": e.type,
|
||||
"path": e.path,
|
||||
"size": e.size,
|
||||
"mode": e.mode,
|
||||
"last_commit": e.last_commit,
|
||||
}
|
||||
for e in entries
|
||||
],
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.error(
|
||||
"Failed to list files for repo %s (path=%s, branch=%s): %s",
|
||||
repo_id,
|
||||
path,
|
||||
branch,
|
||||
str(e),
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def get_file(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
branch: str,
|
||||
path: str,
|
||||
) -> FileContentResponse:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
file_content = get_file_content(repo.path, branch=branch, path=path)
|
||||
return FileContentResponse(
|
||||
path=file_content.path,
|
||||
branch=file_content.branch,
|
||||
content=file_content.content,
|
||||
size=file_content.size,
|
||||
encoding=file_content.encoding,
|
||||
language=file_content.language,
|
||||
is_binary=file_content.is_binary,
|
||||
last_commit=file_content.last_commit,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def update_file(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
data: FileUpdateRequest,
|
||||
user: User,
|
||||
) -> FileUpdateResponse:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
author_name = user.name or "Unknown"
|
||||
author_email = user.email or "unknown@example.com"
|
||||
try:
|
||||
commit_hash = commit_file(
|
||||
repo_path=repo.path,
|
||||
branch=data.branch,
|
||||
path=data.path,
|
||||
content=data.content,
|
||||
commit_message=data.commit_message,
|
||||
author_name=author_name,
|
||||
author_email=author_email,
|
||||
)
|
||||
return FileUpdateResponse(
|
||||
commit_hash=commit_hash,
|
||||
message=data.commit_message,
|
||||
branch=data.branch,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def list_branches_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
) -> dict:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
branches, default_branch = list_branches(repo.path)
|
||||
return {
|
||||
"branches": [
|
||||
{
|
||||
"name": b.name,
|
||||
"is_default": b.is_default,
|
||||
"last_commit": b.last_commit,
|
||||
}
|
||||
for b in branches
|
||||
],
|
||||
"default_branch": default_branch,
|
||||
}
|
||||
except RuntimeError as e:
|
||||
logger.error(
|
||||
"Failed to list branches for repo %s: %s",
|
||||
repo_id,
|
||||
str(e),
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Repository lifecycle and path helpers."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.config import Settings
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.user import User
|
||||
from src.schemas.git_repository import GitRepositoryCreate
|
||||
from src.utils.git_url_parser import parse_git_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
|
||||
"""Generate the filesystem path for a repository."""
|
||||
base = Settings().repo_base_path or "/data/repos"
|
||||
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
|
||||
|
||||
|
||||
def _build_provider_clone_url(owner: str, repo: str) -> str:
|
||||
"""Build the SSH clone URL for the fixed git provider."""
|
||||
return f"git@git.commumedia.org:{owner}/{repo}.git"
|
||||
|
||||
|
||||
def _preflight_remote_repository(remote_url: str) -> None:
|
||||
"""Verify a remote repository is reachable before cloning."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "ls-remote", remote_url],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="repository not found or inaccessible",
|
||||
)
|
||||
|
||||
|
||||
def _clone_working_repository(remote_url: str, repo_path: str) -> None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "clone", remote_url, repo_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"failed to clone repository: {result.stderr}",
|
||||
)
|
||||
|
||||
|
||||
def _init_working_repository(repo_path: str) -> None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "init", "-b", "main", repo_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||
|
||||
if result.returncode == 0:
|
||||
return
|
||||
|
||||
fallback = subprocess.run(
|
||||
["git", "init", repo_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if fallback.returncode != 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"failed to initialize repository: {fallback.stderr}",
|
||||
)
|
||||
|
||||
ref_result = subprocess.run(
|
||||
["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if ref_result.returncode != 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"failed to set initial branch: {ref_result.stderr}",
|
||||
)
|
||||
|
||||
|
||||
async def get_repo_and_validate(
|
||||
session: AsyncSession,
|
||||
repo_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
) -> GitRepository:
|
||||
"""Fetch a repository and validate ownership + disk presence."""
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
return repo
|
||||
|
||||
|
||||
def ensure_repo_on_disk(repo: GitRepository) -> None:
|
||||
"""Raise 404 if the repository is not present on disk."""
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
|
||||
|
||||
async def create_repository(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
data: GitRepositoryCreate,
|
||||
user: User,
|
||||
) -> GitRepository:
|
||||
"""Create a new git repository (clone or init)."""
|
||||
# Check for duplicate name
|
||||
existing = await session.execute(
|
||||
select(GitRepository).where(
|
||||
GitRepository.project_id == project_id,
|
||||
GitRepository.name == data.name,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
||||
|
||||
# Validate and potentially correct the URL
|
||||
remote_url = data.remote_url
|
||||
if remote_url and not data.force_original_url:
|
||||
parse_result = parse_git_url(remote_url)
|
||||
if parse_result["needs_parsing"] and parse_result["base_url"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"message": "The provided URL appears to be a browser URL, not a git clone URL",
|
||||
"suggested_url": parse_result["base_url"],
|
||||
"original_url": remote_url,
|
||||
"error_code": "URL_NEEDS_PARSING",
|
||||
},
|
||||
)
|
||||
if parse_result["base_url"]:
|
||||
remote_url = parse_result["base_url"]
|
||||
|
||||
if remote_url:
|
||||
_preflight_remote_repository(remote_url)
|
||||
|
||||
repo_path = _get_repo_path(user.id, project_id, data.name)
|
||||
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
||||
|
||||
if remote_url:
|
||||
_clone_working_repository(remote_url, repo_path)
|
||||
else:
|
||||
_init_working_repository(repo_path)
|
||||
|
||||
repo = GitRepository(
|
||||
name=data.name,
|
||||
path=repo_path,
|
||||
project_id=project_id,
|
||||
owner_id=user.id,
|
||||
is_mirror=False,
|
||||
remote_url=remote_url,
|
||||
)
|
||||
session.add(repo)
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
return repo
|
||||
|
||||
|
||||
async def delete_repository(
|
||||
session: AsyncSession,
|
||||
repo_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Delete a repository from DB and disk."""
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
|
||||
if os.path.exists(repo.path):
|
||||
shutil.rmtree(repo.path)
|
||||
|
||||
await session.delete(repo)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def list_repositories(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
) -> list[GitRepository]:
|
||||
"""List all repositories in a project."""
|
||||
result = await session.execute(
|
||||
select(GitRepository).where(GitRepository.project_id == project_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,420 @@
|
||||
"""High-level tool instance lifecycle orchestration.
|
||||
|
||||
Coordinates Docker compose, container, tunnel, and config staging services
|
||||
to create, start, stop, restart, and delete tool instances.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.config_folder import ConfigFolder
|
||||
from src.models.config_profile import ConfigProfile
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.tool_config import ToolConfig
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.services.docker import compose as compose_svc
|
||||
from src.services.docker import config_staging
|
||||
from src.services.docker import container as container_svc
|
||||
from src.services.docker import tunnel as tunnel_svc
|
||||
from src.services.docker_build import build_image
|
||||
from src.services.readiness_probe import execute_probe
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_new_instance(
|
||||
session: AsyncSession,
|
||||
project: Project,
|
||||
repo: GitRepository,
|
||||
tool_type: ToolType,
|
||||
user: User,
|
||||
display_name: str | None,
|
||||
selected_profile: ConfigProfile | None,
|
||||
) -> ToolInstance:
|
||||
"""Create a new tool instance record and its compose file."""
|
||||
instance_name = await compose_svc._generate_instance_name(
|
||||
session, project.name, tool_type.name
|
||||
)
|
||||
instance_dir = compose_svc.ensure_instance_directory(instance_name)
|
||||
tool_port = container_svc.find_free_port()
|
||||
|
||||
compose_path = await _build_or_render_compose(
|
||||
tool_type, instance_name, instance_dir, repo, user, project.id, tool_port
|
||||
)
|
||||
|
||||
instance = ToolInstance(
|
||||
name=instance_name,
|
||||
display_name=display_name or f"{project.name} / {repo.name} / {tool_type.display_name}",
|
||||
tool_type_id=tool_type.id,
|
||||
repository_id=repo.id,
|
||||
project_id=project.id,
|
||||
owner_id=user.id,
|
||||
status="pending",
|
||||
compose_path=compose_path,
|
||||
port=tool_port,
|
||||
selected_profile_id=selected_profile.id if selected_profile else None,
|
||||
)
|
||||
session.add(instance)
|
||||
await session.commit()
|
||||
await session.refresh(instance)
|
||||
return instance
|
||||
|
||||
|
||||
async def start_existing_instance(
|
||||
session: AsyncSession,
|
||||
instance: ToolInstance,
|
||||
user: User,
|
||||
project_id: Any,
|
||||
) -> dict:
|
||||
"""Start an existing instance: stage configs, compose up, probe, tunnel."""
|
||||
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found"
|
||||
)
|
||||
|
||||
instance.status = "building"
|
||||
await session.commit()
|
||||
|
||||
env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs(
|
||||
session, user.id, instance.tool_type_id, project_id
|
||||
)
|
||||
|
||||
selected_profile = None
|
||||
if instance.selected_profile_id:
|
||||
selected_profile = await session.get(ConfigProfile, instance.selected_profile_id)
|
||||
if selected_profile and selected_profile.user_id == user.id:
|
||||
instance_dir = os.path.dirname(instance.compose_path)
|
||||
env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile(
|
||||
selected_profile,
|
||||
instance_dir,
|
||||
env_vars,
|
||||
port_override,
|
||||
start_command,
|
||||
working_directory,
|
||||
extra_volumes,
|
||||
)
|
||||
|
||||
env_file_path, extra_volumes = await _stage_configs_and_folders(
|
||||
session, user.id, project_id, os.path.dirname(instance.compose_path),
|
||||
env_vars, config_files, extra_volumes
|
||||
)
|
||||
|
||||
if port_override or start_command or working_directory or extra_volumes:
|
||||
compose_svc._modify_compose_file(
|
||||
instance.compose_path, port_override, start_command, working_directory, extra_volumes
|
||||
)
|
||||
|
||||
returncode, _stdout, stderr = compose_svc.execute_compose_command(
|
||||
instance.compose_path, "up", env_file=env_file_path
|
||||
)
|
||||
if returncode != 0:
|
||||
instance.status = "error"
|
||||
await session.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"failed to start instance: {stderr}",
|
||||
)
|
||||
|
||||
container_id = container_svc.get_container_id(instance.name)
|
||||
if container_id:
|
||||
instance.container_id = container_id
|
||||
container_name = container_svc.get_container_name(instance.name)
|
||||
if container_name:
|
||||
instance.container_name = container_name
|
||||
container_svc.connect_container_to_network(container_name, "backend")
|
||||
|
||||
instance.status = "starting"
|
||||
instance.last_started_at = datetime.now()
|
||||
await session.commit()
|
||||
|
||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||
success, probe_logs = await _run_readiness_probe(instance, tool_type)
|
||||
if not success:
|
||||
instance.status = "failed"
|
||||
instance.url = None
|
||||
instance.public_url = None
|
||||
await session.commit()
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": f"Readiness probe failed: {' '.join(probe_logs)}",
|
||||
}
|
||||
|
||||
instance.status = "running"
|
||||
await session.commit()
|
||||
await _start_tunnel_if_web(instance, tool_type)
|
||||
await session.commit()
|
||||
|
||||
return {"status": instance.status, "url": instance.url}
|
||||
|
||||
|
||||
async def restart_existing_instance(
|
||||
session: AsyncSession,
|
||||
instance: ToolInstance,
|
||||
user: User,
|
||||
project_id: Any,
|
||||
) -> dict:
|
||||
"""Restart an instance: re-stage configs, compose restart, tunnel."""
|
||||
if instance.tunnel_id:
|
||||
try:
|
||||
tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to stop old tunnel: %s", exc)
|
||||
|
||||
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
||||
instance.status = "error"
|
||||
await session.commit()
|
||||
return {"status": instance.status}
|
||||
|
||||
env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs(
|
||||
session, user.id, instance.tool_type_id, project_id
|
||||
)
|
||||
|
||||
stored_profile = None
|
||||
if instance.selected_profile_id:
|
||||
stored_profile = await session.get(ConfigProfile, instance.selected_profile_id)
|
||||
if stored_profile and stored_profile.user_id == user.id:
|
||||
instance_dir = os.path.dirname(instance.compose_path)
|
||||
env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile(
|
||||
stored_profile, instance_dir, env_vars, port_override, start_command, working_directory, extra_volumes
|
||||
)
|
||||
|
||||
env_file_path, extra_volumes = await _stage_configs_and_folders(
|
||||
session, user.id, project_id, os.path.dirname(instance.compose_path),
|
||||
env_vars, config_files, extra_volumes
|
||||
)
|
||||
|
||||
if port_override or start_command or working_directory or extra_volumes:
|
||||
compose_svc._modify_compose_file(
|
||||
instance.compose_path, port_override, start_command, working_directory, extra_volumes
|
||||
)
|
||||
|
||||
returncode, _stdout, _stderr = compose_svc.execute_compose_command(
|
||||
instance.compose_path, "restart", env_file=env_file_path
|
||||
)
|
||||
if returncode != 0:
|
||||
instance.status = "error"
|
||||
await session.commit()
|
||||
return {"status": instance.status}
|
||||
|
||||
instance.status = "running"
|
||||
instance.last_started_at = datetime.now()
|
||||
|
||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||
await _start_tunnel_if_web(instance, tool_type)
|
||||
await session.commit()
|
||||
|
||||
return {"status": instance.status, "url": instance.url}
|
||||
|
||||
|
||||
async def stop_existing_instance(session: AsyncSession, instance: ToolInstance) -> None:
|
||||
"""Stop an instance and its tunnel."""
|
||||
if instance.tunnel_id:
|
||||
try:
|
||||
tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to stop tunnel: %s", exc)
|
||||
|
||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||
compose_svc.execute_compose_command(instance.compose_path, "stop")
|
||||
|
||||
instance.status = "stopped"
|
||||
instance.last_stopped_at = datetime.now()
|
||||
instance.url = None
|
||||
instance.public_url = None
|
||||
instance.tunnel_id = None
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def delete_existing_instance(session: AsyncSession, instance: ToolInstance) -> None:
|
||||
"""Delete an instance, its containers, and its directory."""
|
||||
if instance.tunnel_id:
|
||||
try:
|
||||
tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to stop tunnel: %s", exc)
|
||||
|
||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||
compose_svc.execute_compose_command(instance.compose_path, "down")
|
||||
instance_dir = os.path.dirname(instance.compose_path)
|
||||
if os.path.exists(instance_dir):
|
||||
shutil.rmtree(instance_dir)
|
||||
|
||||
await session.delete(instance)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# ── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
async def _build_or_render_compose(
|
||||
tool_type: ToolType,
|
||||
instance_name: str,
|
||||
instance_dir: str,
|
||||
repo: GitRepository,
|
||||
user: User,
|
||||
project_id: Any,
|
||||
tool_port: int,
|
||||
) -> str:
|
||||
"""Build Dockerfile or render compose template."""
|
||||
if tool_type.definition_type == "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("Build failed for %s: %s", instance_name, stderr)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to build Docker image: {stderr[:500]}",
|
||||
)
|
||||
|
||||
compose_content = (
|
||||
f'version: "3.8"\nservices:\n app:\n'
|
||||
f' image: {image_tag}\n'
|
||||
f' container_name: {instance_name}\n'
|
||||
f' ports:\n - "{tool_port}:{tool_type.default_port}"\n'
|
||||
f' volumes:\n - {repo.path}:/workspace\n'
|
||||
f' restart: unless-stopped\n'
|
||||
)
|
||||
else:
|
||||
variables = {
|
||||
"REPO_PATH": repo.path,
|
||||
"INSTANCE_NAME": instance_name,
|
||||
"INSTANCE_ID": instance_name,
|
||||
"TOOL_NAME": instance_name,
|
||||
"TOOL_PORT": tool_port,
|
||||
"USER_ID": str(user.id),
|
||||
"PROJECT_ID": str(project_id),
|
||||
}
|
||||
compose_content = compose_svc.render_compose_template(
|
||||
tool_type.compose_template, variables
|
||||
)
|
||||
|
||||
compose_svc.write_compose_file(instance_dir, compose_content)
|
||||
return os.path.join(instance_dir, "docker-compose.yml")
|
||||
|
||||
|
||||
async def _fetch_tool_configs(
|
||||
session: AsyncSession,
|
||||
user_id: Any,
|
||||
tool_type_id: Any,
|
||||
project_id: Any,
|
||||
) -> tuple[dict, dict, Any, Any, Any, dict, list]:
|
||||
"""Fetch tool configs and return parsed values."""
|
||||
env_vars: dict[str, str] = {}
|
||||
config_files: dict[str, str] = {}
|
||||
port_override = None
|
||||
start_command = None
|
||||
working_directory = None
|
||||
extra_env_vars: dict[str, str] = {}
|
||||
extra_volumes: list[dict] = []
|
||||
|
||||
query = (
|
||||
select(ToolConfig)
|
||||
.where(ToolConfig.user_id == user_id, ToolConfig.tool_type_id == tool_type_id)
|
||||
.where((ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None)))
|
||||
)
|
||||
configs = (await session.execute(query)).scalars().all()
|
||||
|
||||
for cfg in configs:
|
||||
if cfg.config_type == "env":
|
||||
env_vars[cfg.key] = cfg.value
|
||||
elif cfg.config_type == "file" and cfg.file_path:
|
||||
config_files[cfg.file_path] = cfg.value
|
||||
if cfg.port_override:
|
||||
port_override = cfg.port_override
|
||||
if cfg.start_command:
|
||||
start_command = cfg.start_command
|
||||
if cfg.working_directory:
|
||||
working_directory = cfg.working_directory
|
||||
if cfg.environment_variables:
|
||||
extra_env_vars.update(cfg.environment_variables)
|
||||
if cfg.volumes:
|
||||
extra_volumes.extend(cfg.volumes)
|
||||
|
||||
env_vars.update(extra_env_vars)
|
||||
return env_vars, config_files, port_override, start_command, working_directory, extra_env_vars, extra_volumes
|
||||
|
||||
|
||||
async def _stage_configs_and_folders(
|
||||
session: AsyncSession,
|
||||
user_id: Any,
|
||||
project_id: Any,
|
||||
instance_dir: str,
|
||||
env_vars: dict[str, str],
|
||||
config_files: dict[str, str],
|
||||
extra_volumes: list[dict],
|
||||
) -> tuple[str | None, list[dict]]:
|
||||
"""Write env/config files and config folders."""
|
||||
env_file_path: str | None = None
|
||||
if env_vars:
|
||||
env_file_path = compose_svc.write_env_file(instance_dir, env_vars)
|
||||
if config_files:
|
||||
config_staging.write_config_files(instance_dir, config_files)
|
||||
|
||||
folder_query = select(ConfigFolder).where(
|
||||
ConfigFolder.user_id == user_id, ConfigFolder.is_active.is_(True)
|
||||
)
|
||||
folders = (await session.execute(folder_query)).scalars().all()
|
||||
if folders:
|
||||
folder_volumes = config_staging.write_config_folder_files(
|
||||
instance_dir, folders, str(project_id)
|
||||
)
|
||||
extra_volumes.extend(folder_volumes)
|
||||
|
||||
return env_file_path, extra_volumes
|
||||
|
||||
|
||||
async def _start_tunnel_if_web(instance: ToolInstance, tool_type: ToolType) -> None:
|
||||
"""Create Cloudflare tunnel for web-enabled tools."""
|
||||
if "web" not in tool_type.interfaces or not tool_type.default_port:
|
||||
instance.url = None
|
||||
instance.public_url = None
|
||||
return
|
||||
|
||||
try:
|
||||
tunnel_info = tunnel_svc.start_cloudflared_tunnel(
|
||||
container_name=instance.container_name or instance.name,
|
||||
port=tool_type.default_port,
|
||||
)
|
||||
instance.tunnel_id = tunnel_info["pid"]
|
||||
instance.public_url = tunnel_info["url"]
|
||||
instance.url = tunnel_info["url"]
|
||||
logger.info("Created tunnel for instance %s: %s", instance.id, tunnel_info["url"])
|
||||
except Exception as exc:
|
||||
logger.error("Failed to create tunnel for instance %s: %s", instance.id, exc)
|
||||
instance.status = "error"
|
||||
instance.url = None
|
||||
|
||||
|
||||
async def _run_readiness_probe(
|
||||
instance: ToolInstance, tool_type: ToolType
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Run readiness probe if configured."""
|
||||
if not tool_type.readiness_probe or not instance.container_id:
|
||||
return True, []
|
||||
|
||||
probe = tool_type.readiness_probe
|
||||
command = probe.get("command", "")
|
||||
if not command:
|
||||
return True, []
|
||||
|
||||
return await execute_probe(
|
||||
container_id=instance.container_id,
|
||||
command=command,
|
||||
timeout=probe.get("timeout", 30),
|
||||
interval=probe.get("interval", 2),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -1,426 +1,193 @@
|
||||
"""Terminal session manager for WebSocket connections."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from collections.abc import Coroutine
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from src.database import SessionLocal
|
||||
from src.models.terminal_session import TerminalSessionModel
|
||||
from src.services.terminal_session import TerminalSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MaxSessionsExceededError(Exception):
|
||||
"""Raised when the maximum number of terminal sessions per instance is reached."""
|
||||
|
||||
def __init__(self, instance_id: str, max_sessions: int = 5) -> None:
|
||||
self.instance_id = instance_id
|
||||
self.max_sessions = max_sessions
|
||||
super().__init__(
|
||||
f"Maximum of {max_sessions} terminal sessions reached for instance {instance_id}"
|
||||
)
|
||||
_READ_BATCH_INTERVAL_S = 0.016 # 16ms max batching delay
|
||||
_READ_POLL_TIMEOUT_S = 0.005
|
||||
_READ_POLL_SLEEP_S = 0.001
|
||||
_HEARTBEAT_INTERVAL_S = 15.0
|
||||
_IDLE_TIMEOUT_S = 60.0
|
||||
|
||||
|
||||
class TerminalManager:
|
||||
"""Manages active terminal sessions with persistence support."""
|
||||
|
||||
# Maximum sessions per tool instance
|
||||
MAX_SESSIONS_PER_INSTANCE = 5
|
||||
"""Manages active terminal sessions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Track sessions by (instance_id, session_id) for multi-session support
|
||||
self._sessions: dict[tuple[str, str], TerminalSession] = {}
|
||||
self._idle_check_task: asyncio.Task | None = None
|
||||
self._start_idle_check()
|
||||
|
||||
def _start_idle_check(self) -> None:
|
||||
"""Start the idle timeout background task."""
|
||||
if self._idle_check_task is not None and not self._idle_check_task.done():
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
self._idle_check_task = loop.create_task(self._idle_check_loop())
|
||||
except RuntimeError:
|
||||
# No event loop running yet, will be started lazily
|
||||
pass
|
||||
|
||||
async def _idle_check_loop(self) -> None:
|
||||
"""Periodically check for idle sessions and clean them up."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(60) # Check every minute
|
||||
await self._cleanup_idle_sessions()
|
||||
except Exception as exc:
|
||||
logger.error("Error in idle check loop: %s", exc)
|
||||
|
||||
async def _cleanup_idle_sessions(self) -> None:
|
||||
"""Clean up sessions that have been idle for too long."""
|
||||
idle_keys = []
|
||||
for (instance_id, session_id), session in list(self._sessions.items()):
|
||||
if session.is_idle():
|
||||
idle_keys.append((instance_id, session_id))
|
||||
|
||||
for key in idle_keys:
|
||||
instance_id, session_id = key
|
||||
logger.info(
|
||||
"Cleaning up idle terminal session %s for instance %s",
|
||||
session_id,
|
||||
instance_id,
|
||||
)
|
||||
session = self._sessions.pop(key, None)
|
||||
if session:
|
||||
await session.close()
|
||||
# Update DB status fire-and-forget
|
||||
asyncio.create_task(self._mark_closed_in_db(session_id))
|
||||
|
||||
async def _insert_db_session_row(
|
||||
self,
|
||||
session_id: str,
|
||||
instance_id: uuid.UUID,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Insert a TerminalSessionModel row into the database.
|
||||
|
||||
Uses ON CONFLICT DO NOTHING to handle races when a session is
|
||||
restored from DB and then re-inserted.
|
||||
"""
|
||||
try:
|
||||
async with SessionLocal() as db_session:
|
||||
stmt = (
|
||||
pg_insert(TerminalSessionModel)
|
||||
.values(
|
||||
id=uuid.UUID(session_id),
|
||||
instance_id=instance_id,
|
||||
name=name,
|
||||
status="active",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_activity_at=datetime.now(timezone.utc),
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=["id"])
|
||||
)
|
||||
await db_session.execute(stmt)
|
||||
await db_session.commit()
|
||||
logger.debug(
|
||||
"Inserted terminal session row %s for instance %s",
|
||||
session_id,
|
||||
instance_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to insert terminal session row: %s", exc)
|
||||
|
||||
async def _mark_closed_in_db(self, session_id: str) -> None:
|
||||
"""Mark a terminal session as closed in the database."""
|
||||
try:
|
||||
async with SessionLocal() as db_session:
|
||||
db_row = await db_session.get(
|
||||
TerminalSessionModel, uuid.UUID(session_id)
|
||||
)
|
||||
if db_row:
|
||||
db_row.status = "closed"
|
||||
db_row.closed_at = datetime.now(timezone.utc)
|
||||
await db_session.commit()
|
||||
logger.debug(
|
||||
"Marked terminal session %s as closed in DB", session_id
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to mark terminal session as closed in DB: %s", exc)
|
||||
|
||||
def _count_sessions_for_instance(self, instance_id_str: str) -> int:
|
||||
"""Count active in-memory sessions for a given instance."""
|
||||
return sum(1 for (iid, _sid) in self._sessions if iid == instance_id_str)
|
||||
"""Initialise the terminal manager."""
|
||||
self._sessions: dict[str, TerminalSession] = {}
|
||||
self._last_client_message: dict[str, float] = {}
|
||||
self._background_tasks: set[asyncio.Task[Any]] = set()
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
instance_id: uuid.UUID,
|
||||
container_id: str,
|
||||
startup_command: str | None = None,
|
||||
name: str | None = None,
|
||||
session_id: str | None = None,
|
||||
websocket: WebSocket,
|
||||
) -> TerminalSession:
|
||||
"""Create a new terminal session for an instance.
|
||||
|
||||
Enforces a maximum of MAX_SESSIONS_PER_INSTANCE sessions per instance.
|
||||
Inserts a DB row fire-and-forget.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
container_id: Docker container ID.
|
||||
startup_command: Optional startup command to run.
|
||||
name: Optional session name (auto-generated if omitted).
|
||||
|
||||
Returns:
|
||||
The newly created TerminalSession.
|
||||
|
||||
Raises:
|
||||
MaxSessionsExceededError: If the instance already has max sessions.
|
||||
"""
|
||||
instance_id_str = str(instance_id)
|
||||
|
||||
if (
|
||||
self._count_sessions_for_instance(instance_id_str)
|
||||
>= self.MAX_SESSIONS_PER_INSTANCE
|
||||
):
|
||||
raise MaxSessionsExceededError(
|
||||
instance_id_str, self.MAX_SESSIONS_PER_INSTANCE
|
||||
)
|
||||
|
||||
if session_id is None:
|
||||
session_id = str(uuid.uuid4())
|
||||
session = TerminalSession(
|
||||
session_id=session_id,
|
||||
instance_id=instance_id,
|
||||
container_id=container_id,
|
||||
startup_command=startup_command,
|
||||
name=name,
|
||||
)
|
||||
await session.start(startup_command=startup_command)
|
||||
|
||||
key = (instance_id_str, session_id)
|
||||
self._sessions[key] = session
|
||||
|
||||
# Fire-and-forget DB insert (skip if row already exists)
|
||||
asyncio.create_task(
|
||||
self._insert_db_session_row(session_id, instance_id, session.name)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Created terminal session %s for instance %s (name=%s)",
|
||||
session_id,
|
||||
instance_id,
|
||||
session.name,
|
||||
)
|
||||
return session
|
||||
|
||||
async def get_or_create_session(
|
||||
self,
|
||||
instance_id: uuid.UUID,
|
||||
container_id: str,
|
||||
startup_command: str | None = None,
|
||||
) -> TerminalSession:
|
||||
"""Get existing session or create a new one.
|
||||
|
||||
Backward-compatible alias that uses 'default' as the session_id.
|
||||
"""
|
||||
# Ensure idle check is running (lazy start)
|
||||
self._start_idle_check()
|
||||
|
||||
instance_id_str = str(instance_id)
|
||||
key = (instance_id_str, "default")
|
||||
|
||||
# Check for existing default session
|
||||
if key in self._sessions:
|
||||
session = self._sessions[key]
|
||||
|
||||
# Check if session is still alive
|
||||
if session.is_alive():
|
||||
logger.debug(
|
||||
"Reattaching to existing terminal session for instance %s",
|
||||
instance_id,
|
||||
)
|
||||
return session
|
||||
else:
|
||||
# Session died, clean it up
|
||||
logger.debug(
|
||||
"Existing session for instance %s is dead, cleaning up",
|
||||
instance_id,
|
||||
)
|
||||
await session.close()
|
||||
del self._sessions[key]
|
||||
|
||||
# Create new default session
|
||||
logger.info(
|
||||
"Creating new default terminal session for instance %s", instance_id
|
||||
)
|
||||
"""Create a new terminal session."""
|
||||
session_id = str(uuid.uuid4())
|
||||
session = TerminalSession(
|
||||
session_id=session_id,
|
||||
instance_id=instance_id,
|
||||
container_id=container_id,
|
||||
startup_command=startup_command,
|
||||
name="Session 1",
|
||||
)
|
||||
await session.start(startup_command=startup_command)
|
||||
self._sessions[key] = session
|
||||
session = TerminalSession(session_id, instance_id, container_id)
|
||||
await session.start()
|
||||
self._sessions[session_id] = session
|
||||
self._last_client_message[session_id] = time.monotonic()
|
||||
|
||||
# Fire-and-forget DB insert
|
||||
asyncio.create_task(
|
||||
self._insert_db_session_row(session_id, instance_id, session.name)
|
||||
)
|
||||
# Start background tasks for I/O streaming
|
||||
self._start_task(self._read_loop(session, websocket))
|
||||
self._start_task(self._write_loop(session, websocket))
|
||||
self._start_task(self._heartbeat_loop(session, websocket))
|
||||
|
||||
return session
|
||||
|
||||
def get_session(
|
||||
self,
|
||||
instance_id: str,
|
||||
session_id: str,
|
||||
) -> TerminalSession | None:
|
||||
"""Lookup a session by composite key, or by internal session_id."""
|
||||
session = self._sessions.get((instance_id, session_id))
|
||||
if session is not None:
|
||||
return session
|
||||
# Fallback: search by internal TerminalSession.session_id
|
||||
for (iid, _sid), sess in self._sessions.items():
|
||||
if iid == instance_id and sess.session_id == session_id:
|
||||
return sess
|
||||
return None
|
||||
def _start_task(self, coro: Coroutine[Any, Any, None]) -> None:
|
||||
"""Start a background task and store a reference to prevent GC."""
|
||||
task = asyncio.create_task(coro)
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
def _find_key_by_internal_id(
|
||||
self,
|
||||
instance_id: str,
|
||||
internal_session_id: str,
|
||||
) -> tuple[str, str] | None:
|
||||
"""Find the manager dict key for a session by its internal session_id."""
|
||||
for (iid, sid), session in self._sessions.items():
|
||||
if iid == instance_id and session.session_id == internal_session_id:
|
||||
return (iid, sid)
|
||||
return None
|
||||
|
||||
def get_sessions_for_instance(
|
||||
self,
|
||||
instance_id: str,
|
||||
) -> list[TerminalSession]:
|
||||
"""Return all in-memory sessions for a given instance."""
|
||||
return [
|
||||
session
|
||||
for (iid, _sid), session in self._sessions.items()
|
||||
if iid == instance_id
|
||||
]
|
||||
|
||||
async def close_session(
|
||||
self,
|
||||
instance_id: str,
|
||||
session_id: str,
|
||||
) -> None:
|
||||
"""Close a specific session and update its DB status."""
|
||||
key = (instance_id, session_id)
|
||||
session = self._sessions.pop(key, None)
|
||||
if session:
|
||||
await session.close()
|
||||
# Fire-and-forget DB update
|
||||
asyncio.create_task(self._mark_closed_in_db(session_id))
|
||||
logger.info(
|
||||
"Closed terminal session %s for instance %s",
|
||||
session_id,
|
||||
instance_id,
|
||||
)
|
||||
|
||||
async def attach_websocket(
|
||||
async def _read_loop(
|
||||
self,
|
||||
session: TerminalSession,
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
"""Attach a WebSocket to an existing session.
|
||||
"""Read output from the container and send to WebSocket with batching."""
|
||||
try:
|
||||
buffer = bytearray()
|
||||
last_flush = time.monotonic()
|
||||
|
||||
Closes existing WebSocket connections only for this specific session.
|
||||
"""
|
||||
# Handle concurrent connections - close existing ones within the same session
|
||||
if session.has_websockets():
|
||||
logger.debug(
|
||||
"Closing existing WebSocket connections for session %s (instance %s)",
|
||||
while session.is_alive() and not session.closed:
|
||||
data = await session.read_output(select_timeout=_READ_POLL_TIMEOUT_S)
|
||||
if data:
|
||||
buffer.extend(data)
|
||||
|
||||
now = time.monotonic()
|
||||
flush_due = buffer and (
|
||||
now - last_flush >= _READ_BATCH_INTERVAL_S or not data
|
||||
)
|
||||
|
||||
if flush_due:
|
||||
await websocket.send_bytes(bytes(buffer))
|
||||
buffer.clear()
|
||||
last_flush = now
|
||||
elif not data:
|
||||
await asyncio.sleep(_READ_POLL_SLEEP_S)
|
||||
|
||||
# Flush any remaining data
|
||||
if buffer:
|
||||
with contextlib.suppress(Exception):
|
||||
await websocket.send_bytes(bytes(buffer))
|
||||
|
||||
except Exception:
|
||||
logger.exception("Read loop error for session %s", session.session_id)
|
||||
finally:
|
||||
await self._cleanup_session(session)
|
||||
|
||||
async def _write_loop(
|
||||
self,
|
||||
session: TerminalSession,
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
"""Read input from WebSocket and send to container."""
|
||||
try:
|
||||
while session.is_alive() and not session.closed:
|
||||
message = await websocket.receive()
|
||||
self._last_client_message[session.session_id] = time.monotonic()
|
||||
|
||||
if message["type"] == "websocket.receive":
|
||||
if "bytes" in message:
|
||||
await session.write_input(message["bytes"])
|
||||
elif "text" in message:
|
||||
text = message["text"]
|
||||
if text.startswith("{"):
|
||||
try:
|
||||
ctrl = json.loads(text)
|
||||
await self._handle_control_message(
|
||||
session,
|
||||
websocket,
|
||||
ctrl,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Invalid JSON control message: %s", text)
|
||||
else:
|
||||
await session.write_input(text.encode("utf-8"))
|
||||
elif message["type"] == "websocket.disconnect":
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("Write loop error for session %s", session.session_id)
|
||||
finally:
|
||||
await self._cleanup_session(session)
|
||||
|
||||
async def _handle_control_message(
|
||||
self,
|
||||
session: TerminalSession,
|
||||
websocket: WebSocket,
|
||||
ctrl: dict[str, Any],
|
||||
) -> None:
|
||||
"""Handle a JSON control message from the client."""
|
||||
msg_type = ctrl.get("type")
|
||||
if msg_type == "resize":
|
||||
await session.resize(
|
||||
ctrl.get("cols", 80),
|
||||
ctrl.get("rows", 24),
|
||||
)
|
||||
elif msg_type == "ping":
|
||||
await websocket.send_json(
|
||||
{"type": "pong", "id": ctrl.get("id")},
|
||||
)
|
||||
|
||||
async def _heartbeat_loop(
|
||||
self,
|
||||
session: TerminalSession,
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
"""Monitor client activity and close idle connections."""
|
||||
try:
|
||||
while session.is_alive() and not session.closed:
|
||||
await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
|
||||
last_msg = self._last_client_message.get(session.session_id, 0)
|
||||
if time.monotonic() - last_msg > _IDLE_TIMEOUT_S:
|
||||
# Client has been silent for 60s — close connection
|
||||
with contextlib.suppress(Exception):
|
||||
await websocket.close(
|
||||
code=1000,
|
||||
reason="Idle timeout",
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Heartbeat loop error for session %s",
|
||||
session.session_id,
|
||||
session.instance_id,
|
||||
)
|
||||
for ws in list(session._websockets):
|
||||
try:
|
||||
await ws.close(code=4000, reason="New connection established")
|
||||
except Exception:
|
||||
pass # noqa: S110
|
||||
session._websockets.clear()
|
||||
finally:
|
||||
await self._cleanup_session(session)
|
||||
|
||||
# Attach new WebSocket
|
||||
session.attach_websocket(websocket)
|
||||
|
||||
# Replay buffer
|
||||
buffer = session.get_buffer()
|
||||
if buffer:
|
||||
try:
|
||||
await websocket.send_bytes(buffer)
|
||||
except Exception:
|
||||
pass # noqa: S110
|
||||
|
||||
async def detach_websocket(
|
||||
self,
|
||||
session: TerminalSession,
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
"""Detach a WebSocket from a session."""
|
||||
session.detach_websocket(websocket)
|
||||
|
||||
async def reset_session(
|
||||
self,
|
||||
instance_id: uuid.UUID,
|
||||
container_id: str,
|
||||
startup_command: str | None = None,
|
||||
session_id: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> TerminalSession:
|
||||
"""Reset a session by killing it and creating a new one.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
container_id: Docker container ID.
|
||||
startup_command: Optional startup command.
|
||||
session_id: Specific session to reset. If None, resets the default session.
|
||||
name: Optional name to preserve for the new session.
|
||||
|
||||
Returns:
|
||||
The newly created TerminalSession.
|
||||
"""
|
||||
instance_id_str = str(instance_id)
|
||||
target_session_id = session_id or "default"
|
||||
key = (instance_id_str, target_session_id)
|
||||
|
||||
# Preserve old name if not provided
|
||||
old_name = name
|
||||
if old_name is None and key in self._sessions:
|
||||
old_name = self._sessions[key].name
|
||||
|
||||
# Close existing session if any
|
||||
if key in self._sessions:
|
||||
logger.debug(
|
||||
"Resetting terminal session %s for instance %s",
|
||||
target_session_id,
|
||||
instance_id,
|
||||
)
|
||||
old_session = self._sessions.pop(key)
|
||||
await old_session.close()
|
||||
# Fire-and-forget DB update for old session
|
||||
asyncio.create_task(self._mark_closed_in_db(old_session.session_id))
|
||||
|
||||
# Create new session preserving the same session_id slot
|
||||
new_session_id = str(uuid.uuid4())
|
||||
new_session = TerminalSession(
|
||||
session_id=new_session_id,
|
||||
instance_id=instance_id,
|
||||
container_id=container_id,
|
||||
startup_command=startup_command,
|
||||
name=old_name or ("Session 1" if target_session_id == "default" else None),
|
||||
)
|
||||
await new_session.start(startup_command=startup_command)
|
||||
self._sessions[key] = new_session
|
||||
|
||||
# Fire-and-forget DB insert
|
||||
asyncio.create_task(
|
||||
self._insert_db_session_row(new_session_id, instance_id, new_session.name)
|
||||
)
|
||||
|
||||
return new_session
|
||||
async def _cleanup_session(self, session: TerminalSession) -> None:
|
||||
"""Clean up a session."""
|
||||
if session.session_id in self._sessions:
|
||||
del self._sessions[session.session_id]
|
||||
self._last_client_message.pop(session.session_id, None)
|
||||
await session.close()
|
||||
|
||||
async def close_all(self) -> None:
|
||||
"""Close all active sessions."""
|
||||
sessions = list(self._sessions.values())
|
||||
self._sessions.clear()
|
||||
self._last_client_message.clear()
|
||||
for session in sessions:
|
||||
await session.close()
|
||||
|
||||
if self._idle_check_task and not self._idle_check_task.done():
|
||||
self._idle_check_task.cancel()
|
||||
|
||||
|
||||
# Global terminal manager instance
|
||||
terminal_manager = TerminalManager()
|
||||
|
||||
@@ -1,136 +1,44 @@
|
||||
"""High-performance terminal session with asyncio-native I/O.
|
||||
|
||||
Replaces blocking select.select() with event-driven asyncio.add_reader()
|
||||
for sub-frame latency. Includes output batching and flow control.
|
||||
"""
|
||||
"""Terminal session management for tool instances."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import pty
|
||||
import signal
|
||||
import select
|
||||
import struct
|
||||
import fcntl
|
||||
import time
|
||||
import termios
|
||||
import uuid
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TerminalSession:
|
||||
"""Manages a single terminal session with event-driven PTY I/O.
|
||||
|
||||
Uses asyncio.add_reader() instead of polling for near-zero read latency.
|
||||
Output is batched (2ms window) and sent as binary WebSocket frames.
|
||||
Flow control prevents memory bloat on fast output.
|
||||
"""
|
||||
|
||||
# Circular buffer for replay (10KB)
|
||||
BUFFER_SIZE = 10 * 1024
|
||||
|
||||
# Idle timeout in seconds (30 minutes)
|
||||
IDLE_TIMEOUT = 30 * 60
|
||||
|
||||
# Output batching window in seconds
|
||||
BATCH_WINDOW_S = 0.002 # 2ms
|
||||
|
||||
# Flow control: pause PTY reads when unacknowledged bytes exceed this
|
||||
FLOW_CONTROL_PAUSE = 64 * 1024
|
||||
|
||||
# Flow control: resume PTY reads when unacknowledged bytes drop below this
|
||||
FLOW_CONTROL_RESUME = 32 * 1024
|
||||
|
||||
# Max WebSocket frame size
|
||||
MAX_FRAME_SIZE = 64 * 1024
|
||||
|
||||
# Session number counter per instance_id for auto-naming
|
||||
_instance_counters: dict[str, int] = {}
|
||||
"""Manages a single terminal session connected to a docker container."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str,
|
||||
instance_id: uuid.UUID,
|
||||
container_id: str,
|
||||
startup_command: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize a terminal session."""
|
||||
self.session_id = session_id
|
||||
self.instance_id = instance_id
|
||||
self.container_id = container_id
|
||||
self.startup_command = startup_command
|
||||
self.process: asyncio.subprocess.Process | None = None
|
||||
self._closed = False
|
||||
self._master_fd: int | None = None
|
||||
self._slave_fd: int | None = None
|
||||
self._echo_enabled = True
|
||||
self._exit_reason: str | None = None
|
||||
|
||||
# Circular buffer for output replay
|
||||
self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE)
|
||||
self._buffer_size = 0
|
||||
|
||||
# WebSocket connections
|
||||
self._websockets: set[Any] = set()
|
||||
|
||||
# Activity tracking
|
||||
self.last_activity = time.time()
|
||||
|
||||
# Terminal size
|
||||
self._cols = 80
|
||||
self._rows = 24
|
||||
|
||||
# Session metadata
|
||||
self.name = name or self._generate_name(str(instance_id))
|
||||
self.status: str = "active"
|
||||
|
||||
# Output batching
|
||||
self._batch_buffer = bytearray()
|
||||
self._batch_timer: asyncio.TimerHandle | None = None
|
||||
self._batch_lock = asyncio.Lock()
|
||||
|
||||
# Flow control
|
||||
self._unacknowledged_bytes = 0
|
||||
self._paused = False
|
||||
self._read_handler_set = False
|
||||
self._flow_control_lock = asyncio.Lock()
|
||||
|
||||
# Ack timeout fallback
|
||||
self._ack_timeout_handle: asyncio.TimerHandle | None = None
|
||||
|
||||
@classmethod
|
||||
def _generate_name(cls, instance_id: str) -> str:
|
||||
"""Generate an auto-incremented session name for the instance."""
|
||||
count = cls._instance_counters.get(instance_id, 0) + 1
|
||||
cls._instance_counters[instance_id] = count
|
||||
return f"Session {count}"
|
||||
|
||||
async def start(self, startup_command: str | None = None) -> None:
|
||||
async def start(self) -> None:
|
||||
"""Start the docker exec process with a shell using a PTY."""
|
||||
# Create a pseudo-terminal on the host
|
||||
self._master_fd, slave_fd = pty.openpty()
|
||||
self._master_fd, self._slave_fd = pty.openpty()
|
||||
self._set_terminal_size(80, 24)
|
||||
|
||||
# Set the terminal size initially
|
||||
self._set_terminal_size(self._cols, self._rows)
|
||||
logger.debug(
|
||||
"Starting terminal session %s for container %s with initial size %sx%s",
|
||||
self.session_id,
|
||||
self.container_id,
|
||||
self._cols,
|
||||
self._rows,
|
||||
)
|
||||
|
||||
# Build the shell command
|
||||
cmd = startup_command or self.startup_command
|
||||
if cmd:
|
||||
shell_cmd = f'bash -c "{cmd}" || true; exec bash -il'
|
||||
logger.debug(
|
||||
"Using startup command for session %s: %s",
|
||||
self.session_id,
|
||||
cmd,
|
||||
)
|
||||
else:
|
||||
shell_cmd = "bash -il"
|
||||
|
||||
# Start docker exec with the slave fd as stdin/stdout/stderr
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"exec",
|
||||
@@ -139,287 +47,112 @@ class TerminalSession:
|
||||
"TERM=xterm-256color",
|
||||
self.container_id,
|
||||
"bash",
|
||||
"-c",
|
||||
shell_cmd,
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
"-il",
|
||||
stdin=self._slave_fd,
|
||||
stdout=self._slave_fd,
|
||||
stderr=self._slave_fd,
|
||||
)
|
||||
|
||||
# Close slave fd in parent process
|
||||
os.close(slave_fd)
|
||||
os.close(self._slave_fd)
|
||||
self._slave_fd = None
|
||||
self._echo_enabled = self._detect_echo_state()
|
||||
|
||||
self.last_activity = time.time()
|
||||
|
||||
# Start event-driven reading
|
||||
self._start_reading()
|
||||
|
||||
def _start_reading(self) -> None:
|
||||
"""Register PTY master fd with asyncio event loop for event-driven reads."""
|
||||
if self._read_handler_set or self._master_fd is None or self._closed:
|
||||
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
||||
"""Set the terminal size using TIOCSWINSZ."""
|
||||
if self._master_fd is None:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.add_reader(self._master_fd, self._on_fd_readable)
|
||||
self._read_handler_set = True
|
||||
logger.debug("Started event-driven reading for session %s", self.session_id)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to start reading for session %s: %s", self.session_id, exc
|
||||
)
|
||||
tiocswinsz = 0x5414
|
||||
size = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
with contextlib.suppress(OSError):
|
||||
fcntl.ioctl(self._master_fd, tiocswinsz, size)
|
||||
|
||||
def _stop_reading(self) -> None:
|
||||
"""Unregister PTY master fd from asyncio event loop."""
|
||||
if not self._read_handler_set or self._master_fd is None:
|
||||
return
|
||||
def _detect_echo_state(self) -> bool:
|
||||
"""Detect whether the PTY has echo enabled via termios."""
|
||||
if self._master_fd is None:
|
||||
return True
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.remove_reader(self._master_fd)
|
||||
self._read_handler_set = False
|
||||
except Exception:
|
||||
pass
|
||||
attrs = termios.tcgetattr(self._master_fd)
|
||||
return bool(attrs[3] & termios.ECHO)
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
def _on_fd_readable(self) -> None:
|
||||
"""Callback when PTY master fd has data available (called by event loop)."""
|
||||
async def check_echo_state(self) -> bool | None:
|
||||
"""Check if echo state changed. Returns new state if changed, None otherwise."""
|
||||
current = self._detect_echo_state()
|
||||
if current != self._echo_enabled:
|
||||
self._echo_enabled = current
|
||||
return current
|
||||
return None
|
||||
|
||||
@property
|
||||
def echo_enabled(self) -> bool:
|
||||
"""Return whether the PTY currently has echo enabled."""
|
||||
return self._echo_enabled
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
"""Return whether the session has been closed."""
|
||||
return self._closed
|
||||
|
||||
async def read_output(self, select_timeout: float = 0.1) -> bytes:
|
||||
"""Read output from the PTY master."""
|
||||
if self._master_fd is None or self._closed:
|
||||
return
|
||||
|
||||
return b""
|
||||
try:
|
||||
data = os.read(self._master_fd, 4096)
|
||||
except (OSError, IOError) as exc:
|
||||
logger.debug("PTY read error for session %s: %s", self.session_id, exc)
|
||||
self._handle_eof()
|
||||
return
|
||||
|
||||
if not data:
|
||||
# EOF: docker exec process exited
|
||||
logger.debug("PTY EOF for session %s", self.session_id)
|
||||
self._handle_eof()
|
||||
return
|
||||
|
||||
self._add_to_buffer(data)
|
||||
self.last_activity = time.time()
|
||||
|
||||
# Queue for batching + flow control
|
||||
self._queue_output(data)
|
||||
|
||||
def _add_to_buffer(self, data: bytes) -> None:
|
||||
"""Add data to circular buffer, maintaining size limit."""
|
||||
self._output_buffer.append(data)
|
||||
self._buffer_size += len(data)
|
||||
while self._buffer_size > self.BUFFER_SIZE and self._output_buffer:
|
||||
removed = self._output_buffer.popleft()
|
||||
self._buffer_size -= len(removed)
|
||||
|
||||
def _queue_output(self, data: bytes) -> None:
|
||||
"""Add output to batch buffer and schedule flush."""
|
||||
self._batch_buffer.extend(data)
|
||||
self._unacknowledged_bytes += len(data)
|
||||
|
||||
# Check flow control
|
||||
if self._unacknowledged_bytes > self.FLOW_CONTROL_PAUSE and not self._paused:
|
||||
self._pause_output()
|
||||
|
||||
# Schedule batch flush if not already scheduled
|
||||
if self._batch_timer is None:
|
||||
loop = asyncio.get_event_loop()
|
||||
self._batch_timer = loop.call_later(
|
||||
self.BATCH_WINDOW_S,
|
||||
self._flush_batch_sync,
|
||||
readable, _, _ = select.select(
|
||||
[self._master_fd],
|
||||
[],
|
||||
[],
|
||||
select_timeout,
|
||||
)
|
||||
|
||||
def _flush_batch_sync(self) -> None:
|
||||
"""Synchronous entry point for batch flush (called from event loop)."""
|
||||
self._batch_timer = None
|
||||
if not self._batch_buffer or not self._websockets:
|
||||
self._batch_buffer.clear()
|
||||
return
|
||||
|
||||
payload = bytes(self._batch_buffer)
|
||||
self._batch_buffer.clear()
|
||||
|
||||
# Send to all websockets (asyncio.create_task for async send)
|
||||
dead_sockets = set()
|
||||
for ws in list(self._websockets):
|
||||
try:
|
||||
asyncio.create_task(self._send_bytes(ws, payload))
|
||||
except Exception:
|
||||
dead_sockets.add(ws)
|
||||
|
||||
if dead_sockets:
|
||||
self._websockets -= dead_sockets
|
||||
|
||||
async def _send_bytes(self, ws: Any, payload: bytes) -> None:
|
||||
"""Send bytes to a single websocket, catching errors."""
|
||||
try:
|
||||
await ws.send_bytes(payload)
|
||||
except Exception:
|
||||
self._websockets.discard(ws)
|
||||
|
||||
def acknowledge_data(self, char_count: int) -> None:
|
||||
"""Client acknowledges processing char_count bytes.
|
||||
|
||||
Called from the WebSocket handler when the client sends an 'ack' message.
|
||||
"""
|
||||
self._unacknowledged_bytes = max(0, self._unacknowledged_bytes - char_count)
|
||||
|
||||
if self._paused and self._unacknowledged_bytes < self.FLOW_CONTROL_RESUME:
|
||||
self._resume_output()
|
||||
|
||||
# Reset ack timeout
|
||||
if self._ack_timeout_handle:
|
||||
self._ack_timeout_handle.cancel()
|
||||
loop = asyncio.get_event_loop()
|
||||
self._ack_timeout_handle = loop.call_later(5.0, self._ack_timeout_fallback)
|
||||
|
||||
def _ack_timeout_fallback(self) -> None:
|
||||
"""If no ack received for 5s, assume client is dead and resume."""
|
||||
logger.warning(
|
||||
"Flow control ack timeout for session %s, resuming output",
|
||||
self.session_id,
|
||||
)
|
||||
self._unacknowledged_bytes = 0
|
||||
if self._paused:
|
||||
self._resume_output()
|
||||
|
||||
def _pause_output(self) -> None:
|
||||
"""Pause reading from PTY due to flow control."""
|
||||
self._paused = True
|
||||
self._stop_reading()
|
||||
logger.debug(
|
||||
"Paused output for session %s (%d unacked)",
|
||||
self.session_id,
|
||||
self._unacknowledged_bytes,
|
||||
)
|
||||
|
||||
def _resume_output(self) -> None:
|
||||
"""Resume reading from PTY."""
|
||||
self._paused = False
|
||||
self._start_reading()
|
||||
logger.debug("Resumed output for session %s", self.session_id)
|
||||
|
||||
def get_buffer(self) -> bytes:
|
||||
"""Get buffered output for replay."""
|
||||
return b"".join(self._output_buffer)
|
||||
|
||||
def _handle_eof(self) -> None:
|
||||
"""Handle PTY EOF: process died, close websockets to force reconnect."""
|
||||
self._stop_reading()
|
||||
# Mark process as done so is_alive() returns False
|
||||
if self.process is not None and self.process.returncode is None:
|
||||
# Force returncode to a non-None value since the process is dead
|
||||
# but asyncio.subprocess may not have set it yet
|
||||
try:
|
||||
self.process._transport.close() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
# Close all websockets to force frontend reconnection
|
||||
dead_sockets = set(self._websockets)
|
||||
self._websockets.clear()
|
||||
for ws in dead_sockets:
|
||||
try:
|
||||
asyncio.create_task(
|
||||
ws.close(code=4001, reason="Session process exited")
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Session %s EOF handled, websockets closed", self.session_id)
|
||||
if readable:
|
||||
return os.read(self._master_fd, 8192)
|
||||
return b""
|
||||
except (OSError, ValueError):
|
||||
return b""
|
||||
|
||||
async def write_input(self, data: bytes) -> None:
|
||||
"""Write input to the PTY master."""
|
||||
if self._master_fd is None or self._closed:
|
||||
return
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.write(self._master_fd, data)
|
||||
self.last_activity = time.time()
|
||||
except (OSError, IOError) as exc:
|
||||
logger.debug("PTY write error for session %s: %s", self.session_id, exc)
|
||||
self._handle_eof()
|
||||
|
||||
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
||||
"""Set the terminal size using TIOCSWINSZ."""
|
||||
if self._master_fd is None:
|
||||
logger.warning("Cannot resize: master_fd is None (session not started)")
|
||||
return
|
||||
TIOCSWINSZ = 0x5414
|
||||
size = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
try:
|
||||
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
||||
logger.debug("Resized PTY to %sx%s (fd=%s)", cols, rows, self._master_fd)
|
||||
except (OSError, IOError) as e:
|
||||
logger.error("Failed to resize PTY: %s", e)
|
||||
|
||||
async def resize(self, cols: int, rows: int) -> None:
|
||||
"""Resize the terminal."""
|
||||
if self._closed:
|
||||
logger.warning("Cannot resize: session is closed")
|
||||
return
|
||||
|
||||
if cols == self._cols and rows == self._rows:
|
||||
return
|
||||
|
||||
self._cols = cols
|
||||
self._rows = rows
|
||||
logger.debug(
|
||||
"resize() called for session %s: %sx%s", self.session_id, cols, rows
|
||||
)
|
||||
self._set_terminal_size(cols, rows)
|
||||
|
||||
# Send SIGWINCH to docker exec process
|
||||
if self.process and self.process.pid:
|
||||
try:
|
||||
os.kill(self.process.pid, signal.SIGWINCH)
|
||||
except ProcessLookupError:
|
||||
logger.warning("docker exec process %s not found", self.process.pid)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to send SIGWINCH: %s", e)
|
||||
|
||||
async def reset(self) -> None:
|
||||
"""Reset the session by killing the process and clearing state."""
|
||||
self.status = "resetting"
|
||||
await self.close()
|
||||
self._closed = False
|
||||
self._output_buffer.clear()
|
||||
self._buffer_size = 0
|
||||
self._websockets.clear()
|
||||
self._batch_buffer.clear()
|
||||
self._batch_timer = None
|
||||
self._unacknowledged_bytes = 0
|
||||
self._paused = False
|
||||
self._read_handler_set = False
|
||||
self.process = None
|
||||
self._master_fd = None
|
||||
self.status = "active"
|
||||
def get_exit_reason(self) -> str | None:
|
||||
"""Return the reason the session ended, if known."""
|
||||
return self._exit_reason
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the session and cleanup."""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
self.status = "closed"
|
||||
|
||||
self._stop_reading()
|
||||
|
||||
if self._batch_timer:
|
||||
self._batch_timer.cancel()
|
||||
self._batch_timer = None
|
||||
|
||||
if self._ack_timeout_handle:
|
||||
self._ack_timeout_handle.cancel()
|
||||
self._ack_timeout_handle = None
|
||||
# Determine exit reason
|
||||
if self.process is not None and self.process.returncode is not None:
|
||||
if self.process.returncode == 0:
|
||||
self._exit_reason = "process_exit"
|
||||
else:
|
||||
self._exit_reason = "process_exit"
|
||||
else:
|
||||
self._exit_reason = "timeout"
|
||||
|
||||
if self._master_fd is not None:
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(self._master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self._master_fd = None
|
||||
|
||||
if self.process is not None:
|
||||
try:
|
||||
self.process.kill()
|
||||
await asyncio.wait_for(self.process.wait(), timeout=2.0)
|
||||
except (asyncio.TimeoutError, ProcessLookupError):
|
||||
except (TimeoutError, ProcessLookupError):
|
||||
pass
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
@@ -427,41 +160,3 @@ class TerminalSession:
|
||||
if self.process is None:
|
||||
return False
|
||||
return self.process.returncode is None
|
||||
|
||||
def is_idle(self) -> bool:
|
||||
"""Check if the session has been idle for too long."""
|
||||
if self._websockets:
|
||||
return False
|
||||
return time.time() - self.last_activity > self.IDLE_TIMEOUT
|
||||
|
||||
def attach_websocket(self, websocket: Any) -> None:
|
||||
"""Attach a WebSocket to this session."""
|
||||
self._websockets.add(websocket)
|
||||
self.last_activity = time.time()
|
||||
|
||||
def detach_websocket(self, websocket: Any) -> None:
|
||||
"""Detach a WebSocket from this session."""
|
||||
self._websockets.discard(websocket)
|
||||
|
||||
def has_websockets(self) -> bool:
|
||||
"""Check if any WebSockets are attached."""
|
||||
return len(self._websockets) > 0
|
||||
|
||||
async def send_to_all(self, data: bytes) -> None:
|
||||
"""Send data to all attached WebSockets (used for control messages)."""
|
||||
dead_sockets = set()
|
||||
for ws in self._websockets:
|
||||
try:
|
||||
await ws.send_bytes(data)
|
||||
except Exception:
|
||||
dead_sockets.add(ws)
|
||||
for ws in dead_sockets:
|
||||
self._websockets.discard(ws)
|
||||
|
||||
async def read_output(self) -> bytes:
|
||||
"""Legacy method: read output synchronously.
|
||||
|
||||
With event-driven I/O, output is automatically sent to websockets.
|
||||
This method returns any buffered data for callers that poll.
|
||||
"""
|
||||
return b""
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _run_git_command(repo_path: str, *args: str) -> str:
|
||||
@@ -123,15 +124,7 @@ def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None:
|
||||
try:
|
||||
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}")
|
||||
except RuntimeError:
|
||||
# No commits yet - empty repository
|
||||
try:
|
||||
_run_git_command(repo_path, "checkout", "--orphan", name)
|
||||
except RuntimeError as e:
|
||||
if "work tree" in str(e).lower():
|
||||
# Bare repository - use symbolic-ref instead
|
||||
_run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}")
|
||||
return
|
||||
raise
|
||||
_run_git_command(repo_path, "checkout", "--orphan", name)
|
||||
return
|
||||
|
||||
_run_git_command(repo_path, "branch", name, base_branch)
|
||||
@@ -162,14 +155,7 @@ def checkout_branch(repo_path: str, name: str) -> None:
|
||||
Raises:
|
||||
RuntimeError: If checkout fails
|
||||
"""
|
||||
try:
|
||||
_run_git_command(repo_path, "checkout", name)
|
||||
except RuntimeError as e:
|
||||
if "work tree" in str(e).lower():
|
||||
# Bare repository - use symbolic-ref instead
|
||||
_run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}")
|
||||
return
|
||||
raise
|
||||
_run_git_command(repo_path, "checkout", name)
|
||||
|
||||
|
||||
def commit_changes(
|
||||
@@ -304,10 +290,6 @@ def get_current_branch(repo_path: str) -> str:
|
||||
Current branch name
|
||||
"""
|
||||
try:
|
||||
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
||||
if branch != "HEAD":
|
||||
return branch
|
||||
return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
||||
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
||||
|
||||
@@ -289,37 +289,19 @@ def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]:
|
||||
branches: list[BranchInfo] = []
|
||||
default_branch = "main"
|
||||
|
||||
# Get list of remote names to properly filter remote tracking branches
|
||||
try:
|
||||
remote_output = _run_git_command(repo_path, "remote")
|
||||
remote_names = {r.strip() for r in remote_output.strip().split("\n") if r.strip()}
|
||||
except RuntimeError:
|
||||
remote_names = set()
|
||||
|
||||
for line in output.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
|
||||
branch_name = line.strip()
|
||||
|
||||
# Skip detached HEAD pointer
|
||||
if branch_name == "HEAD":
|
||||
continue
|
||||
|
||||
# Skip remote tracking branches - they appear as "origin/branch-name"
|
||||
# Check if first part is a remote name
|
||||
if "/" in branch_name:
|
||||
first_part = branch_name.split("/", 1)[0]
|
||||
if first_part in remote_names:
|
||||
# Extract just the branch name part (after "origin/")
|
||||
branch_name = branch_name.split("/", 1)[1]
|
||||
elif branch_name.startswith("remotes/"):
|
||||
# Handle "remotes/origin/branch-name" format
|
||||
parts = branch_name.split("/", 2)
|
||||
if len(parts) >= 3:
|
||||
branch_name = parts[2]
|
||||
else:
|
||||
continue
|
||||
# Skip remote tracking branches (they start with remotes/)
|
||||
if branch_name.startswith("remotes/"):
|
||||
# Extract just the branch name part
|
||||
parts = branch_name.split("/", 2)
|
||||
if len(parts) >= 3:
|
||||
branch_name = parts[2]
|
||||
else:
|
||||
continue
|
||||
|
||||
# Skip duplicates
|
||||
if any(b.name == branch_name for b in branches):
|
||||
|
||||
@@ -60,9 +60,12 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1
|
||||
|
||||
Returns structured data including commits, branches, and graph information.
|
||||
"""
|
||||
# Get list of branches
|
||||
branches_output = _run_git_command(repo_path, ["branch", "-a", "--format=%(refname:short)"])
|
||||
branches = [b.strip() for b in branches_output.strip().split("\n") if b.strip()]
|
||||
# Get list of branches (may fail for empty repos)
|
||||
try:
|
||||
branches_output = _run_git_command(repo_path, ["branch", "-a", "--format=%(refname:short)"])
|
||||
branches = [b.strip() for b in branches_output.strip().split("\n") if b.strip()]
|
||||
except RuntimeError:
|
||||
branches = []
|
||||
|
||||
# Build git log command - use NULL bytes as separators to avoid parsing issues
|
||||
log_args = [
|
||||
@@ -76,7 +79,16 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1
|
||||
else:
|
||||
log_args.append("--all")
|
||||
|
||||
log_output = _run_git_command(repo_path, log_args)
|
||||
try:
|
||||
log_output = _run_git_command(repo_path, log_args)
|
||||
except RuntimeError:
|
||||
# Empty repo or no commits
|
||||
return {
|
||||
"commits": [],
|
||||
"branches": branches,
|
||||
"total_commits": 0,
|
||||
"graph_data": {"nodes": [], "edges": []},
|
||||
}
|
||||
|
||||
# Get branch info for each commit
|
||||
branch_map = _get_branch_map(repo_path)
|
||||
@@ -113,8 +125,11 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1
|
||||
)
|
||||
|
||||
# Get total commit count
|
||||
count_output = _run_git_command(repo_path, ["rev-list", "--all", "--count"])
|
||||
total_commits = int(count_output.strip()) if count_output.strip() else 0
|
||||
try:
|
||||
count_output = _run_git_command(repo_path, ["rev-list", "--all", "--count"])
|
||||
total_commits = int(count_output.strip()) if count_output.strip() else 0
|
||||
except RuntimeError:
|
||||
total_commits = 0
|
||||
|
||||
# Build graph data and generate graph symbols
|
||||
graph_data = _build_graph_data(commits)
|
||||
|
||||
@@ -8,14 +8,16 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi.testclient import TestClient
|
||||
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
|
||||
from src.config import Settings, build_database_url
|
||||
from src.models.base import Base
|
||||
from src.main import app
|
||||
from src.auth.dependencies import get_db_session
|
||||
@@ -45,8 +47,10 @@ def test_client() -> Generator[TestClient, None, None]:
|
||||
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||
|
||||
# Patch startup events to prevent PostgreSQL connection attempts
|
||||
with patch("src.main.init_database") as mock_init:
|
||||
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:
|
||||
@@ -57,31 +61,6 @@ def test_client() -> Generator[TestClient, None, None]:
|
||||
asyncio.run(engine.dispose())
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(test_client) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Provide an async database session for unit tests."""
|
||||
# Get the override function from the test_client fixture
|
||||
override_fn = app.dependency_overrides.get(get_db_session)
|
||||
if override_fn:
|
||||
gen = override_fn()
|
||||
session = await gen.asend(None)
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await gen.aclose()
|
||||
else:
|
||||
# Fallback: create a new engine and session
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
||||
"""Provide an authenticated test client with a test user."""
|
||||
@@ -131,65 +110,6 @@ def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_project_and_repo(authenticated_client) -> tuple[str, str]:
|
||||
"""Create a project and repository directly in the database."""
|
||||
import uuid
|
||||
from src.models.project import Project
|
||||
from src.models.git_repository import GitRepository
|
||||
|
||||
project_id = uuid.uuid4()
|
||||
repo_id = uuid.uuid4()
|
||||
user_id = None
|
||||
|
||||
# Get user ID from session
|
||||
async def get_user_id():
|
||||
nonlocal user_id
|
||||
from src.auth.session import decode_session_cookie
|
||||
settings = Settings()
|
||||
session_cookie = authenticated_client.cookies.get("session")
|
||||
if session_cookie:
|
||||
session = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||
if session:
|
||||
user_id = uuid.UUID(session["user_id"])
|
||||
|
||||
asyncio.run(get_user_id())
|
||||
|
||||
if not user_id:
|
||||
raise RuntimeError("Could not get user ID from authenticated client")
|
||||
|
||||
async def create_project_and_repo():
|
||||
override_fn = app.dependency_overrides.get(get_db_session)
|
||||
if override_fn:
|
||||
gen = override_fn()
|
||||
session = await gen.asend(None)
|
||||
try:
|
||||
project = Project(
|
||||
id=project_id,
|
||||
name="test-project",
|
||||
description="Test project",
|
||||
owner_id=user_id,
|
||||
)
|
||||
session.add(project)
|
||||
|
||||
repo = GitRepository(
|
||||
id=repo_id,
|
||||
name="test-repo",
|
||||
path="/tmp/test-repo",
|
||||
project_id=project_id,
|
||||
owner_id=user_id,
|
||||
remote_url="https://github.com/test/repo.git",
|
||||
)
|
||||
session.add(repo)
|
||||
await session.commit()
|
||||
finally:
|
||||
await gen.aclose()
|
||||
|
||||
asyncio.run(create_project_and_repo())
|
||||
|
||||
return str(project_id), str(repo_id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(test_client) -> Generator[TestClient, None, None]:
|
||||
"""Provide an authenticated test client with an admin user."""
|
||||
|
||||
@@ -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"]
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Integration tests for config profiles API."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -17,7 +20,9 @@ class TestConfigProfilesAPI:
|
||||
response = authenticated_client.get("/config-profiles")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
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."""
|
||||
@@ -26,99 +31,48 @@ class TestConfigProfilesAPI:
|
||||
json={
|
||||
"name": "test-profile",
|
||||
"description": "Test profile",
|
||||
"env_vars": {"VAR": "value"},
|
||||
"runtime_hints": {"start_command": "npm start"},
|
||||
"mounts": [{"target": "/app", "mode": "rw", "files": {}}],
|
||||
"files": {"test.txt": "hello"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "test-profile"
|
||||
assert data["env_vars"] == {"VAR": "value"}
|
||||
assert data["files"] == {"test.txt": "hello"}
|
||||
assert data["mounts"][0]["target"] == "/app"
|
||||
assert data["description"] == "Test profile"
|
||||
|
||||
def test_create_config_profile_duplicate_name(self, authenticated_client: TestClient) -> None:
|
||||
"""Test that duplicate profile names are rejected."""
|
||||
# Create first profile
|
||||
response = authenticated_client.post(
|
||||
authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "duplicate-profile",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
},
|
||||
json={"name": "duplicate-profile"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
# Try to create second with same name
|
||||
response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "duplicate-profile",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
},
|
||||
json={"name": "duplicate-profile"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
def test_create_config_profile_exceeds_size_limit(self, authenticated_client: TestClient) -> None:
|
||||
"""Test that profiles exceeding 10MB are rejected."""
|
||||
large_content = "x" * (11 * 1024 * 1024) # 11MB
|
||||
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": "large-profile",
|
||||
"env_vars": {},
|
||||
"files": {"large.txt": large_content},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 413
|
||||
|
||||
def test_create_config_profile_invalid_file_path(self, authenticated_client: TestClient) -> None:
|
||||
"""Test that invalid file paths are rejected."""
|
||||
response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "bad-profile",
|
||||
"env_vars": {},
|
||||
"files": {"../../../etc/passwd": "malicious"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_create_config_profile_invalid_mount_target(self, authenticated_client: TestClient) -> None:
|
||||
"""Test that invalid mount targets are rejected."""
|
||||
response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "bad-mount-profile",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
"mounts": [{"target": "relative/path", "mode": "rw", "files": {}}],
|
||||
},
|
||||
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 profile first
|
||||
create_response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "get-test",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
},
|
||||
json={"name": "get-test"},
|
||||
)
|
||||
profile_id = create_response.json()["id"]
|
||||
|
||||
# Get it back
|
||||
response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "get-test"
|
||||
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."""
|
||||
@@ -127,327 +81,381 @@ class TestConfigProfilesAPI:
|
||||
|
||||
def test_update_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||
"""Test updating a config profile."""
|
||||
# Create profile first
|
||||
create_response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "update-test",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
},
|
||||
json={"name": "update-test"},
|
||||
)
|
||||
profile_id = create_response.json()["id"]
|
||||
|
||||
# Update it
|
||||
response = authenticated_client.put(
|
||||
f"/config-profiles/{profile_id}",
|
||||
json={
|
||||
"name": "updated-name",
|
||||
"env_vars": {"NEW_VAR": "new_value"},
|
||||
},
|
||||
json={"name": "updated-name", "description": "updated desc"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "updated-name"
|
||||
assert data["env_vars"] == {"NEW_VAR": "new_value"}
|
||||
assert data["description"] == "updated desc"
|
||||
|
||||
def test_delete_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||
"""Test deleting a config profile."""
|
||||
# Create profile first
|
||||
create_response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "delete-test",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
},
|
||||
json={"name": "delete-test"},
|
||||
)
|
||||
profile_id = create_response.json()["id"]
|
||||
|
||||
# Delete it
|
||||
response = authenticated_client.delete(f"/config-profiles/{profile_id}")
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify it's gone
|
||||
get_response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
||||
assert get_response.status_code == 404
|
||||
|
||||
def test_update_profile_includes_successfully(self, authenticated_client: TestClient) -> None:
|
||||
"""Test updating profile includes."""
|
||||
# Create base profile
|
||||
base_response = authenticated_client.post(
|
||||
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": "base-profile",
|
||||
"env_vars": {"BASE_VAR": "base_value"},
|
||||
"files": {},
|
||||
},
|
||||
json={"name": "access-test"},
|
||||
)
|
||||
base_id = base_response.json()["id"]
|
||||
profile_id = create_response.json()["id"]
|
||||
|
||||
# Create child profile
|
||||
child_response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "child-profile",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
},
|
||||
)
|
||||
child_id = child_response.json()["id"]
|
||||
|
||||
# Update includes
|
||||
response = authenticated_client.put(
|
||||
f"/config-profiles/{child_id}/includes",
|
||||
json={"includes": [base_id]},
|
||||
)
|
||||
# The profile should be accessible
|
||||
response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
print(f"Response data: {data}")
|
||||
print(f"Includes: {data.get('includes', 'NO INCLUDES KEY')}")
|
||||
assert len(data["includes"]) == 1, f"Expected 1 include, got {len(data.get('includes', []))}: {data.get('includes', [])}"
|
||||
assert data["includes"][0]["included_profile_id"] == base_id
|
||||
|
||||
def test_update_profile_includes_cycle_detection(self, authenticated_client: TestClient) -> None:
|
||||
"""Test that include cycles are detected."""
|
||||
# Create profile A
|
||||
a_response = authenticated_client.post(
|
||||
|
||||
@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-a",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
},
|
||||
)
|
||||
a_id = a_response.json()["id"]
|
||||
|
||||
# Create profile B
|
||||
b_response = authenticated_client.post(
|
||||
json={"name": "profile-1"},
|
||||
).json()
|
||||
profile2 = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "profile-b",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
},
|
||||
)
|
||||
b_id = b_response.json()["id"]
|
||||
|
||||
# Make B include A
|
||||
authenticated_client.put(
|
||||
f"/config-profiles/{b_id}/includes",
|
||||
json={"includes": [a_id]},
|
||||
)
|
||||
|
||||
# Try to make A include B (would create cycle)
|
||||
response = authenticated_client.put(
|
||||
f"/config-profiles/{a_id}/includes",
|
||||
json={"includes": [b_id]},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_preview_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||
"""Test previewing a resolved config profile."""
|
||||
# Create base profile
|
||||
base_response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "preview-base",
|
||||
"env_vars": {"BASE_VAR": "base"},
|
||||
"files": {},
|
||||
},
|
||||
)
|
||||
base_id = base_response.json()["id"]
|
||||
|
||||
# Create child profile
|
||||
child_response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "preview-child",
|
||||
"env_vars": {"CHILD_VAR": "child"},
|
||||
"files": {},
|
||||
},
|
||||
)
|
||||
child_id = child_response.json()["id"]
|
||||
|
||||
# Make child include base
|
||||
authenticated_client.put(
|
||||
f"/config-profiles/{child_id}/includes",
|
||||
json={"includes": [base_id]},
|
||||
)
|
||||
|
||||
# Preview child
|
||||
response = authenticated_client.get(f"/config-profiles/{child_id}/preview")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["profile_name"] == "preview-child"
|
||||
assert data["env_vars"]["BASE_VAR"] == "base"
|
||||
assert data["env_vars"]["CHILD_VAR"] == "child"
|
||||
assert len(data["included_profiles"]) == 1
|
||||
|
||||
def test_resolve_default_profile(self, authenticated_client: TestClient) -> None:
|
||||
"""Test resolving default profile for project/tool."""
|
||||
# Create a global default profile (no project/tool scoping)
|
||||
authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "default-profile",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
"is_default": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Resolve default with random project/tool (should fall back to global)
|
||||
project_id = str(uuid.uuid4())
|
||||
tool_type_id = str(uuid.uuid4())
|
||||
response = authenticated_client.get(
|
||||
"/config-profiles/defaults/resolve",
|
||||
params={"project_id": project_id, "tool_type_id": tool_type_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["profile_name"] == "default-profile"
|
||||
|
||||
def test_resolve_default_profile_no_match(self, authenticated_client: TestClient) -> None:
|
||||
"""Test resolving default profile when no profiles exist."""
|
||||
project_id = str(uuid.uuid4())
|
||||
tool_type_id = str(uuid.uuid4())
|
||||
|
||||
response = authenticated_client.get(
|
||||
"/config-profiles/defaults/resolve",
|
||||
params={"project_id": project_id, "tool_type_id": tool_type_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["profile_id"] is None
|
||||
|
||||
def test_create_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||
"""Test creating a config profile with git mounts."""
|
||||
_project_id, repo_id = test_project_and_repo
|
||||
json={"name": "profile-2"},
|
||||
).json()
|
||||
|
||||
# Add include
|
||||
response = authenticated_client.post(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "git-mount-profile",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
"git_mounts": [
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"source_path": ".",
|
||||
"target_path": "/app",
|
||||
"branch": "main",
|
||||
}
|
||||
],
|
||||
},
|
||||
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["name"] == "git-mount-profile"
|
||||
assert len(data["git_mounts"]) == 1
|
||||
assert data["git_mounts"][0]["target_path"] == "/app"
|
||||
assert data["git_mounts"][0]["branch"] == "main"
|
||||
assert data["included_profile_id"] == profile2["id"]
|
||||
assert data["included_profile_name"] == "profile-2"
|
||||
|
||||
def test_update_config_profile_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||
"""Test updating git mounts on a config profile."""
|
||||
_project_id, repo_id = test_project_and_repo
|
||||
|
||||
# Create profile first
|
||||
create_response = authenticated_client.post(
|
||||
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": "update-git-mounts",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
},
|
||||
)
|
||||
profile_id = create_response.json()["id"]
|
||||
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()
|
||||
|
||||
# Update with git mounts
|
||||
response = authenticated_client.put(
|
||||
f"/config-profiles/{profile_id}",
|
||||
json={
|
||||
"git_mounts": [
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"source_path": "config",
|
||||
"target_path": "/config",
|
||||
}
|
||||
],
|
||||
},
|
||||
f"/config-profiles/{p1['id']}/includes/{inc['id']}",
|
||||
json={"order_index": 5},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["git_mounts"]) == 1
|
||||
assert data["git_mounts"][0]["source_path"] == "config"
|
||||
assert response.json()["order_index"] == 5
|
||||
|
||||
def test_create_config_profile_invalid_git_mount_source_path(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||
"""Test that invalid git mount source paths are rejected."""
|
||||
_project_id, repo_id = test_project_and_repo
|
||||
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": "bad-git-mount",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
"git_mounts": [
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"source_path": "/absolute/path",
|
||||
"target_path": "/app",
|
||||
}
|
||||
],
|
||||
},
|
||||
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_create_config_profile_invalid_git_mount_target_path_traversal(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||
"""Test that git mount target paths with traversal are rejected."""
|
||||
_project_id, repo_id = test_project_and_repo
|
||||
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(
|
||||
"/config-profiles",
|
||||
json={
|
||||
"name": "bad-git-mount-target",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
"git_mounts": [
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"source_path": ".",
|
||||
"target_path": "../../../etc/passwd",
|
||||
}
|
||||
],
|
||||
},
|
||||
f"/config-profiles/{profile['id']}/mounts",
|
||||
json={"target_path": "/etc/../passwd", "files": {"test.txt": "hello"}},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_preview_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||
"""Test previewing a profile with git mounts."""
|
||||
_project_id, repo_id = test_project_and_repo
|
||||
|
||||
# Create profile with git mounts
|
||||
create_response = authenticated_client.post(
|
||||
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": "preview-git-mounts",
|
||||
"env_vars": {},
|
||||
"files": {},
|
||||
"git_mounts": [
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"source_path": ".",
|
||||
"target_path": "/app",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
profile_id = create_response.json()["id"]
|
||||
json={"name": "dup-mount-test"},
|
||||
).json()
|
||||
|
||||
# Preview
|
||||
response = authenticated_client.get(f"/config-profiles/{profile_id}/preview")
|
||||
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 len(data["git_mounts"]) == 1
|
||||
assert data["git_mounts"][0]["remote_url"] == "https://github.com/user/repo.git"
|
||||
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
|
||||
|
||||
@@ -70,20 +70,6 @@ def test_get_current_branch_handles_unborn_main() -> None:
|
||||
assert get_current_branch(tmpdir) == "main"
|
||||
|
||||
|
||||
def test_create_branch_on_bare_repo_with_no_commits() -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1")
|
||||
create_branch(f"{tmpdir}/bare.git", "main")
|
||||
assert get_current_branch(f"{tmpdir}/bare.git") == "main"
|
||||
|
||||
|
||||
def test_checkout_branch_on_bare_repo_with_no_commits() -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1")
|
||||
checkout_branch(f"{tmpdir}/bare.git", "main")
|
||||
assert get_current_branch(f"{tmpdir}/bare.git") == "main"
|
||||
|
||||
|
||||
class TestBranchOperations:
|
||||
"""Tests for branch management functions."""
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ def test_base_metadata_collects_declared_tables() -> None:
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
def test_shared_mixins_define_expected_columns() -> None:
|
||||
assert "id" in UUIDPrimaryKeyMixin.__dict__
|
||||
assert "created_at" in TimestampMixin.__dict__
|
||||
@@ -23,26 +24,20 @@ def test_shared_mixins_define_expected_columns() -> None:
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
def test_expected_tables_are_registered() -> None:
|
||||
assert set(Base.metadata.tables) == {
|
||||
"config_profile_includes",
|
||||
"config_profiles",
|
||||
"refresh_tokens",
|
||||
"git_repositories",
|
||||
"health_checks",
|
||||
"instance_events",
|
||||
"notifications",
|
||||
"projects",
|
||||
"ssh_keys",
|
||||
"terminal_sessions",
|
||||
"tool_definition_manifests",
|
||||
"tool_instances",
|
||||
"tool_types",
|
||||
"user_configs",
|
||||
"users",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
def test_user_table_has_required_columns() -> None:
|
||||
columns = User.__table__.columns
|
||||
|
||||
@@ -61,6 +56,7 @@ def test_user_table_has_required_columns() -> None:
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
def test_project_relationships_point_to_owner_and_default_ssh_key() -> None:
|
||||
owner_fk = next(iter(Project.__table__.c.owner_id.foreign_keys))
|
||||
ssh_fk = next(iter(Project.__table__.c.default_ssh_key_id.foreign_keys))
|
||||
@@ -72,6 +68,7 @@ def test_project_relationships_point_to_owner_and_default_ssh_key() -> None:
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
def test_repository_and_user_config_relationships_are_registered() -> None:
|
||||
project_fk = next(iter(GitRepository.__table__.c.project_id.foreign_keys))
|
||||
owner_fk = next(iter(GitRepository.__table__.c.owner_id.foreign_keys))
|
||||
@@ -85,15 +82,33 @@ def test_repository_and_user_config_relationships_are_registered() -> None:
|
||||
assert UserConfig.user.property.mapper.class_ is User
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
def test_refresh_token_table_has_required_columns_and_relationships() -> None:
|
||||
columns = RefreshToken.__table__.columns
|
||||
user_fk = next(iter(RefreshToken.__table__.c.user_id.foreign_keys))
|
||||
|
||||
assert set(columns.keys()) == {
|
||||
"id",
|
||||
"user_id",
|
||||
"token_hash",
|
||||
"expires_at",
|
||||
"revoked_at",
|
||||
"user_agent",
|
||||
"ip_address",
|
||||
"created_at",
|
||||
}
|
||||
assert columns["token_hash"].unique is True
|
||||
assert columns["revoked_at"].nullable is True
|
||||
assert user_fk.target_fullname == "users.id"
|
||||
assert RefreshToken.user.property.mapper.class_ is User
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
|
||||
async def test_async_session_can_insert_and_load_user(db_session: AsyncSession) -> None:
|
||||
user = User(
|
||||
email="dev@headquarter.local",
|
||||
name="Dev User",
|
||||
authentik_id="dev-user",
|
||||
avatar_url=None,
|
||||
)
|
||||
user = User(email="dev@headquarter.local", name="Dev User", authentik_id="dev-user", avatar_url=None)
|
||||
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
|
||||
@@ -59,7 +58,7 @@ def _mint_token(user_id: str) -> str:
|
||||
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
|
||||
@@ -59,7 +59,7 @@ def _mint_token(user_id: str) -> str:
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ def _insert_tool_type(
|
||||
name: str,
|
||||
display_name: str,
|
||||
compose_template: str,
|
||||
is_builtin: bool = False,
|
||||
created_by_id: str | None = None,
|
||||
) -> None:
|
||||
async def _run() -> None:
|
||||
@@ -119,6 +120,7 @@ def _insert_tool_type(
|
||||
description="A test tool type",
|
||||
compose_template=compose_template,
|
||||
required_variables=["REPO_PATH", "TOOL_NAME"],
|
||||
is_builtin=is_builtin,
|
||||
created_by_id=uuid.UUID(created_by_id) if created_by_id else None,
|
||||
)
|
||||
await session.merge(tool_type)
|
||||
@@ -232,6 +234,7 @@ def test_create_tool_type_successfully() -> None:
|
||||
assert data["name"] == "my-custom-tool"
|
||||
assert data["display_name"] == "My Custom Tool"
|
||||
assert data["description"] == "A custom development tool"
|
||||
assert data["is_builtin"] == False
|
||||
assert data["created_by_id"] == user_id
|
||||
assert "id" in data
|
||||
|
||||
@@ -373,7 +376,28 @@ def test_update_tool_type_not_found() -> None:
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_update_builtin_tool_type_fails() -> None:
|
||||
_prepare_test_db()
|
||||
user_id = "11111111-1111-1111-1111-111111111111"
|
||||
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
_insert_user(user_id)
|
||||
_insert_tool_type(
|
||||
tool_type_id,
|
||||
"builtin-tool",
|
||||
"Built-in Tool",
|
||||
"version: '3.8'\nservices:\n app:\n image: builtin",
|
||||
is_builtin=True,
|
||||
)
|
||||
|
||||
app = _load_app()
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _mint_token(user_id))
|
||||
|
||||
payload = {"display_name": "Updated"}
|
||||
response = client.put(f"/tool-types/{tool_type_id}", json=payload)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -418,4 +442,53 @@ def test_delete_tool_type_not_found() -> None:
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_delete_builtin_tool_type_fails() -> None:
|
||||
_prepare_test_db()
|
||||
user_id = "11111111-1111-1111-1111-111111111111"
|
||||
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
_insert_user(user_id)
|
||||
_insert_tool_type(
|
||||
tool_type_id,
|
||||
"builtin-tool",
|
||||
"Built-in Tool",
|
||||
"version: '3.8'\nservices:\n app:\n image: builtin",
|
||||
is_builtin=True,
|
||||
)
|
||||
|
||||
app = _load_app()
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _mint_token(user_id))
|
||||
|
||||
response = client.delete(f"/tool-types/{tool_type_id}")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_builtin_tool_types_seeded_on_startup() -> None:
|
||||
_prepare_test_db()
|
||||
user_id = "11111111-1111-1111-1111-111111111111"
|
||||
_insert_user(user_id)
|
||||
|
||||
# Load app triggers startup event which seeds built-in types
|
||||
app = _load_app()
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _mint_token(user_id))
|
||||
|
||||
response = client.get("/tool-types")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check that built-in types exist
|
||||
builtin_names = [t["name"] for t in data if t["is_builtin"]]
|
||||
assert "code-server" in builtin_names
|
||||
assert "jupyter-notebook" in builtin_names
|
||||
|
||||
# Verify built-in types have correct attributes
|
||||
code_server = next((t for t in data if t["name"] == "code-server"), None)
|
||||
assert code_server is not None
|
||||
assert code_server["display_name"] == "VS Code Server"
|
||||
assert "services" in code_server["compose_template"]
|
||||
assert code_server["required_variables"] == ["REPO_PATH", "TOOL_NAME"]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -6,9 +7,7 @@ from fastapi.testclient import TestClient
|
||||
class TestToolTypesAPIExtended:
|
||||
"""Integration tests for tool types API with new fields."""
|
||||
|
||||
def test_create_tool_type_with_dockerfile(
|
||||
self, authenticated_client: TestClient
|
||||
) -> None:
|
||||
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",
|
||||
@@ -29,9 +28,7 @@ class TestToolTypesAPIExtended:
|
||||
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:
|
||||
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",
|
||||
@@ -42,7 +39,7 @@ class TestToolTypesAPIExtended:
|
||||
"interfaces": ["web"],
|
||||
"default_port": 8080,
|
||||
"definition_type": "compose",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||
"readiness_probe": {
|
||||
"command": "curl -f http://localhost:8080",
|
||||
"timeout": 30,
|
||||
@@ -56,9 +53,7 @@ class TestToolTypesAPIExtended:
|
||||
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:
|
||||
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",
|
||||
@@ -73,9 +68,7 @@ class TestToolTypesAPIExtended:
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_create_tool_type_dockerfile_without_template(
|
||||
self, authenticated_client: TestClient
|
||||
) -> None:
|
||||
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",
|
||||
@@ -89,9 +82,7 @@ class TestToolTypesAPIExtended:
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_update_tool_type_with_new_fields(
|
||||
self, authenticated_client: TestClient
|
||||
) -> None:
|
||||
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(
|
||||
@@ -101,7 +92,7 @@ class TestToolTypesAPIExtended:
|
||||
"display_name": "Update Test Tool",
|
||||
"default_port": 8080,
|
||||
"definition_type": "compose",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||
"required_variables": [],
|
||||
},
|
||||
)
|
||||
@@ -122,9 +113,7 @@ class TestToolTypesAPIExtended:
|
||||
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"
|
||||
)
|
||||
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."""
|
||||
@@ -139,9 +128,7 @@ class TestToolTypesAPIExtended:
|
||||
data = response.json()
|
||||
assert data["valid"] is True
|
||||
|
||||
def test_validate_tool_type_invalid_compose(
|
||||
self, authenticated_client: TestClient
|
||||
) -> None:
|
||||
def test_validate_tool_type_invalid_compose(self, authenticated_client: TestClient) -> None:
|
||||
"""Test validating invalid compose template."""
|
||||
response = authenticated_client.post(
|
||||
"/tool-types/validate",
|
||||
@@ -155,9 +142,7 @@ class TestToolTypesAPIExtended:
|
||||
assert data["valid"] is False
|
||||
assert "errors" in data
|
||||
|
||||
def test_validate_tool_type_dockerfile(
|
||||
self, authenticated_client: TestClient
|
||||
) -> None:
|
||||
def test_validate_tool_type_dockerfile(self, authenticated_client: TestClient) -> None:
|
||||
"""Test validating dockerfile template."""
|
||||
response = authenticated_client.post(
|
||||
"/tool-types/validate",
|
||||
@@ -170,9 +155,7 @@ class TestToolTypesAPIExtended:
|
||||
data = response.json()
|
||||
assert data["valid"] is True
|
||||
|
||||
def test_get_tool_type_returns_new_fields(
|
||||
self, authenticated_client: TestClient
|
||||
) -> None:
|
||||
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(
|
||||
@@ -184,7 +167,7 @@ class TestToolTypesAPIExtended:
|
||||
"interfaces": ["web", "terminal"],
|
||||
"default_port": 8443,
|
||||
"definition_type": "compose",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n command: --bind-addr 0.0.0.0:8443\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
|
||||
"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,
|
||||
@@ -203,124 +186,3 @@ class TestToolTypesAPIExtended:
|
||||
assert data["category"] == "editor"
|
||||
assert data["interfaces"] == ["web", "terminal"]
|
||||
assert "readiness_probe" in data
|
||||
|
||||
def test_create_tool_type_without_port_fails(
|
||||
self, authenticated_client: TestClient
|
||||
) -> None:
|
||||
"""Test that creating a tool type without default_port fails validation."""
|
||||
response = authenticated_client.post(
|
||||
"/tool-types",
|
||||
json={
|
||||
"name": "no-port-tool",
|
||||
"display_name": "No Port Tool",
|
||||
"category": "utility",
|
||||
"interfaces": ["web"],
|
||||
"definition_type": "compose",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
|
||||
"required_variables": [],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
data = response.json()
|
||||
assert "default_port" in str(data)
|
||||
|
||||
def test_create_tool_type_with_port_mismatch_fails(
|
||||
self, authenticated_client: TestClient
|
||||
) -> None:
|
||||
"""Test that port mismatch between default_port and compose template fails."""
|
||||
response = authenticated_client.post(
|
||||
"/tool-types",
|
||||
json={
|
||||
"name": "port-mismatch-tool",
|
||||
"display_name": "Port Mismatch Tool",
|
||||
"category": "utility",
|
||||
"interfaces": ["web"],
|
||||
"default_port": 9999,
|
||||
"definition_type": "compose",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
|
||||
"required_variables": [],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
_ = response.json()
|
||||
|
||||
def test_create_tool_type_with_startup_command(
|
||||
self, authenticated_client: TestClient
|
||||
) -> None:
|
||||
"""Test creating a tool type with startup_command."""
|
||||
response = authenticated_client.post(
|
||||
"/tool-types",
|
||||
json={
|
||||
"name": "startup-tool",
|
||||
"display_name": "Startup Tool",
|
||||
"category": "utility",
|
||||
"interface_type": "terminal",
|
||||
"requires_port": False,
|
||||
"default_port": 0,
|
||||
"definition_type": "compose",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
|
||||
"startup_command": "cd /workspace && ls",
|
||||
"required_variables": [],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["startup_command"] == "cd /workspace && ls"
|
||||
assert data["interface_type"] == "terminal"
|
||||
|
||||
def test_update_tool_type_startup_command(
|
||||
self, authenticated_client: TestClient
|
||||
) -> None:
|
||||
"""Test updating a tool type's startup_command."""
|
||||
# Create tool type first
|
||||
create_response = authenticated_client.post(
|
||||
"/tool-types",
|
||||
json={
|
||||
"name": "update-startup-tool",
|
||||
"display_name": "Update Startup Tool",
|
||||
"interface_type": "terminal",
|
||||
"requires_port": False,
|
||||
"default_port": 0,
|
||||
"definition_type": "compose",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
|
||||
"required_variables": [],
|
||||
},
|
||||
)
|
||||
tool_id = create_response.json()["id"]
|
||||
|
||||
# Update with startup_command
|
||||
response = authenticated_client.put(
|
||||
f"/tool-types/{tool_id}",
|
||||
json={
|
||||
"startup_command": "source /etc/profile",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["startup_command"] == "source /etc/profile"
|
||||
|
||||
def test_get_tool_type_returns_startup_command(
|
||||
self, authenticated_client: TestClient
|
||||
) -> None:
|
||||
"""Test that GET returns startup_command."""
|
||||
create_response = authenticated_client.post(
|
||||
"/tool-types",
|
||||
json={
|
||||
"name": "get-startup-tool",
|
||||
"display_name": "Get Startup Tool",
|
||||
"interface_type": "terminal",
|
||||
"requires_port": False,
|
||||
"default_port": 0,
|
||||
"definition_type": "compose",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
|
||||
"startup_command": "echo hello",
|
||||
"required_variables": [],
|
||||
},
|
||||
)
|
||||
tool_id = create_response.json()["id"]
|
||||
|
||||
response = authenticated_client.get(f"/tool-types/{tool_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["startup_command"] == "echo hello"
|
||||
assert "Port 9999 is not exposed" in str(data)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import asyncio
|
||||
import io
|
||||
|
||||
@@ -83,7 +83,7 @@ def _create_auth_cookie(user_id: str) -> str:
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for git URL parsing utilities."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.utils.git_url_parser import extract_base_repo_url, is_valid_clone_url, parse_git_url
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Unit tests for readiness probe service."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.readiness_probe import execute_probe
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Unit tests for TerminalManager."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.terminal_manager import TerminalManager
|
||||
from src.services.terminal_session import TerminalSession
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager():
|
||||
return TerminalManager()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_websocket():
|
||||
ws = AsyncMock()
|
||||
ws.send_bytes = AsyncMock()
|
||||
ws.send_json = AsyncMock()
|
||||
ws.close = AsyncMock()
|
||||
ws.receive = AsyncMock()
|
||||
return ws
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session():
|
||||
session = MagicMock(spec=TerminalSession)
|
||||
session.session_id = "sess-123"
|
||||
session.is_alive.return_value = True
|
||||
session._closed = False
|
||||
session.read_output = AsyncMock(return_value=b"")
|
||||
session.write_input = AsyncMock()
|
||||
session.resize = AsyncMock()
|
||||
session.close = AsyncMock()
|
||||
session.get_exit_reason.return_value = None
|
||||
return session
|
||||
|
||||
|
||||
class TestCreateSession:
|
||||
@patch("src.services.terminal_manager.asyncio.create_task")
|
||||
@patch("src.services.terminal_manager.uuid.uuid4", return_value="sess-123")
|
||||
async def test_create_session_registers_and_starts_loops(
|
||||
self, mock_uuid, mock_create_task, manager, mock_websocket
|
||||
):
|
||||
instance_id = __import__("uuid").uuid4()
|
||||
mock_sess = MagicMock()
|
||||
mock_sess.session_id = "sess-123"
|
||||
mock_sess.is_alive.return_value = True
|
||||
mock_sess._closed = False
|
||||
mock_sess.start = AsyncMock()
|
||||
mock_sess.read_output = AsyncMock(return_value=b"")
|
||||
mock_sess.write_input = AsyncMock()
|
||||
mock_sess.resize = AsyncMock()
|
||||
mock_sess.close = AsyncMock()
|
||||
mock_sess.get_exit_reason.return_value = None
|
||||
|
||||
with (
|
||||
patch.object(manager, "_read_loop", new=AsyncMock()),
|
||||
patch.object(manager, "_write_loop", new=AsyncMock()),
|
||||
patch.object(manager, "_heartbeat_loop", new=AsyncMock()),
|
||||
patch(
|
||||
"src.services.terminal_manager.TerminalSession",
|
||||
return_value=mock_sess,
|
||||
),
|
||||
):
|
||||
session = await manager.create_session(
|
||||
instance_id, "container-abc", mock_websocket
|
||||
)
|
||||
assert session.session_id == "sess-123"
|
||||
assert "sess-123" in manager._sessions
|
||||
assert "sess-123" in manager._last_client_message
|
||||
|
||||
|
||||
class TestHandleControlMessage:
|
||||
async def test_handle_resize(self, manager, mock_session, mock_websocket):
|
||||
ctrl = {"type": "resize", "cols": 120, "rows": 40}
|
||||
await manager._handle_control_message(mock_session, mock_websocket, ctrl)
|
||||
mock_session.resize.assert_awaited_once_with(120, 40)
|
||||
|
||||
async def test_handle_ping(self, manager, mock_session, mock_websocket):
|
||||
ctrl = {"type": "ping", "id": 42}
|
||||
await manager._handle_control_message(mock_session, mock_websocket, ctrl)
|
||||
mock_websocket.send_json.assert_awaited_once_with({"type": "pong", "id": 42})
|
||||
|
||||
async def test_handle_unknown_type(self, manager, mock_session, mock_websocket):
|
||||
ctrl = {"type": "unknown", "data": "test"}
|
||||
await manager._handle_control_message(mock_session, mock_websocket, ctrl)
|
||||
mock_websocket.send_json.assert_not_awaited()
|
||||
mock_session.resize.assert_not_awaited()
|
||||
|
||||
|
||||
class TestCleanupSession:
|
||||
async def test_cleanup_removes_session(self, manager, mock_session):
|
||||
manager._sessions["sess-123"] = mock_session
|
||||
manager._last_client_message["sess-123"] = 123.0
|
||||
|
||||
await manager._cleanup_session(mock_session)
|
||||
assert "sess-123" not in manager._sessions
|
||||
assert "sess-123" not in manager._last_client_message
|
||||
mock_session.close.assert_awaited_once()
|
||||
|
||||
|
||||
class TestCloseAll:
|
||||
async def test_close_all_clears_sessions(self, manager, mock_session):
|
||||
manager._sessions["sess-123"] = mock_session
|
||||
manager._last_client_message["sess-123"] = 123.0
|
||||
|
||||
await manager.close_all()
|
||||
assert len(manager._sessions) == 0
|
||||
assert len(manager._last_client_message) == 0
|
||||
mock_session.close.assert_awaited_once()
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Unit tests for TerminalSession."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.terminal_session import TerminalSession
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pty():
|
||||
"""Mock pty.openpty to return predictable fds."""
|
||||
master_fd = 10
|
||||
slave_fd = 11
|
||||
with (
|
||||
patch(
|
||||
"src.services.terminal_session.pty.openpty",
|
||||
return_value=(master_fd, slave_fd),
|
||||
),
|
||||
patch("src.services.terminal_session.os.close") as mock_close,
|
||||
):
|
||||
yield master_fd, slave_fd, mock_close
|
||||
|
||||
|
||||
class TestTerminalSessionStart:
|
||||
def test_init_state(self, mock_pty):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
|
||||
assert session.session_id == "sess-1"
|
||||
assert session.container_id == "container-abc"
|
||||
assert session._echo_enabled is True
|
||||
assert session._exit_reason is None
|
||||
|
||||
|
||||
class TestTerminalSessionEchoDetection:
|
||||
@patch("src.services.terminal_session.termios.tcgetattr")
|
||||
def test_detect_echo_state_enabled(self, mock_tcgetattr):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._master_fd = 10
|
||||
|
||||
# termios.ECHO flag set
|
||||
attrs = [[], [], [], __import__("termios").ECHO, [], [], []]
|
||||
mock_tcgetattr.return_value = attrs
|
||||
|
||||
result = session._detect_echo_state()
|
||||
assert result is True
|
||||
|
||||
@patch("src.services.terminal_session.termios.tcgetattr")
|
||||
def test_detect_echo_state_disabled(self, mock_tcgetattr):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._master_fd = 10
|
||||
|
||||
# termios.ECHO flag NOT set
|
||||
attrs = [[], [], [], 0, [], [], []]
|
||||
mock_tcgetattr.return_value = attrs
|
||||
|
||||
result = session._detect_echo_state()
|
||||
assert result is False
|
||||
|
||||
def test_detect_echo_state_no_master_fd(self):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._master_fd = None
|
||||
|
||||
result = session._detect_echo_state()
|
||||
assert result is True # default
|
||||
|
||||
|
||||
class TestTerminalSessionResize:
|
||||
@patch("src.services.terminal_session.fcntl.ioctl")
|
||||
def test_resize_sets_size(self, mock_ioctl):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._master_fd = 10
|
||||
|
||||
# Should not raise
|
||||
asyncio.run(session.resize(120, 40))
|
||||
mock_ioctl.assert_called_once()
|
||||
|
||||
def test_resize_when_closed(self):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._closed = True
|
||||
|
||||
# Should not raise
|
||||
asyncio.run(session.resize(120, 40))
|
||||
|
||||
|
||||
class TestTerminalSessionWriteInput:
|
||||
@patch("src.services.terminal_session.os.write")
|
||||
def test_write_input(self, mock_write):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._master_fd = 10
|
||||
|
||||
asyncio.run(session.write_input(b"hello"))
|
||||
mock_write.assert_called_once_with(10, b"hello")
|
||||
|
||||
def test_write_input_when_closed(self):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._closed = True
|
||||
|
||||
# Should not raise
|
||||
asyncio.run(session.write_input(b"hello"))
|
||||
|
||||
|
||||
class TestTerminalSessionReadOutput:
|
||||
@patch("src.services.terminal_session.select.select")
|
||||
@patch("src.services.terminal_session.os.read")
|
||||
def test_read_output_with_data(self, mock_read, mock_select):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._master_fd = 10
|
||||
|
||||
mock_select.return_value = ([10], [], [])
|
||||
mock_read.return_value = b"output"
|
||||
|
||||
result = asyncio.run(session.read_output())
|
||||
assert result == b"output"
|
||||
|
||||
@patch("src.services.terminal_session.select.select")
|
||||
def test_read_output_no_data(self, mock_select):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._master_fd = 10
|
||||
|
||||
mock_select.return_value = ([], [], [])
|
||||
|
||||
result = asyncio.run(session.read_output())
|
||||
assert result == b""
|
||||
|
||||
|
||||
class TestTerminalSessionClose:
|
||||
@patch("src.services.terminal_session.os.close")
|
||||
@patch("src.services.terminal_session.asyncio.wait_for")
|
||||
async def test_close_sets_exit_reason(self, mock_wait_for, mock_close):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._master_fd = 10
|
||||
session.process = MagicMock()
|
||||
session.process.returncode = 0
|
||||
|
||||
await session.close()
|
||||
assert session._exit_reason == "process_exit"
|
||||
assert session._closed is True
|
||||
|
||||
async def test_close_idempotent(self):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._closed = True
|
||||
|
||||
# Should not raise
|
||||
await session.close()
|
||||
|
||||
|
||||
class TestTerminalSessionIsAlive:
|
||||
def test_is_alive_with_running_process(self):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session.process = MagicMock()
|
||||
session.process.returncode = None
|
||||
|
||||
assert session.is_alive() is True
|
||||
|
||||
def test_is_alive_with_exited_process(self):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session.process = MagicMock()
|
||||
session.process.returncode = 0
|
||||
|
||||
assert session.is_alive() is False
|
||||
|
||||
def test_is_alive_no_process(self):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session.process = None
|
||||
|
||||
assert session.is_alive() is False
|
||||
Reference in New Issue
Block a user