Merge branch 'fix/container-name-case-sensitivity' into dev

This commit is contained in:
Alex Blank
2026-05-28 10:58:18 +02:00
45 changed files with 1022 additions and 866 deletions
@@ -7,8 +7,6 @@ Create Date: 2026-05-22 21:50:00.000000
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "0014_merge_heads" revision: str = "0014_merge_heads"
@@ -10,7 +10,6 @@ from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "0015_single_interface" revision: str = "0015_single_interface"
@@ -1,5 +1,3 @@
import json
"""add pi agent tool type """add pi agent tool type
Revision ID: 20260527_160017_add_pi_agent Revision ID: 20260527_160017_add_pi_agent
@@ -7,6 +5,8 @@ Revises: f3d2dc90ba3a
Create Date: 2026-05-27T16:00:17 Create Date: 2026-05-27T16:00:17
""" """
import json
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -5,8 +5,6 @@ Revises: 2026_05_23_remove_is_builtin, 2026_05_24_add_config_profiles
Create Date: 2026-05-24 18:00:43.990361 Create Date: 2026-05-24 18:00:43.990361
""" """
from alembic import op
import sqlalchemy as sa
@@ -7,8 +7,6 @@ Create Date: 2026-05-24 10:43:14.000000
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "f3d2dc90ba3a" revision: str = "f3d2dc90ba3a"
-1
View File
@@ -12,7 +12,6 @@ from sqlalchemy.orm import selectinload
from src.api.shared_validators import validate_env_vars as _validate_env_vars from src.api.shared_validators import validate_env_vars as _validate_env_vars
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.config_profile import ConfigProfile, ConfigProfileInclude from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.tool_type import ToolType from src.models.tool_type import ToolType
from src.services.config_profile_resolver import ( from src.services.config_profile_resolver import (
-2
View File
@@ -13,9 +13,7 @@ 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_owned_project, _get_user, get_current_user_id, get_db_session
from src.config import Settings from src.config import Settings
from src.models.git_repository import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey from src.models.ssh_key import SSHKey
from src.models.user import User
from src.utils.git_files import ( from src.utils.git_files import (
commit_file, commit_file,
get_file_content, get_file_content,
+1 -2
View File
@@ -4,11 +4,10 @@ import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import APIRouter, status from fastapi import APIRouter
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import text from sqlalchemy import text
from src.config import Settings
from src.database import SessionLocal from src.database import SessionLocal
router = APIRouter() router = APIRouter()
-1
View File
@@ -2,7 +2,6 @@
import logging import logging
import uuid import uuid
from typing import Any
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
-1
View File
@@ -11,7 +11,6 @@ from src.auth.dependencies import _get_owned_project, _get_user, get_current_use
from src.models.git_repository import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey from src.models.ssh_key import SSHKey
from src.models.user import User
router = APIRouter(prefix="/projects", tags=["projects"]) router = APIRouter(prefix="/projects", tags=["projects"])
-1
View File
@@ -1,6 +1,5 @@
"""Shared Pydantic validators for API schemas.""" """Shared Pydantic validators for API schemas."""
from typing import Any
MAX_FOLDER_SIZE_MB = 10 MAX_FOLDER_SIZE_MB = 10
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024 MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
-1
View File
@@ -13,7 +13,6 @@ 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_user, get_current_user_id, get_db_session
from src.config import Settings from src.config import Settings
from src.models.ssh_key import SSHKey from src.models.ssh_key import SSHKey
from src.models.user import User
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"]) router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
+1 -1
View File
@@ -4,7 +4,7 @@ import asyncio
import logging import logging
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_db_session from src.auth.dependencies import get_db_session
+1 -1
View File
@@ -1,6 +1,5 @@
"""Tool configuration API endpoints.""" """Tool configuration API endpoints."""
import logging
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@@ -11,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.api.shared_validators import validate_env_vars as _validate_env_vars, validate_volumes as _validate_volumes from src.api.shared_validators import validate_env_vars as _validate_env_vars, validate_volumes as _validate_volumes
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_config import ToolConfig from src.models.tool_config import ToolConfig
from src.models.tool_type import ToolType
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"]) router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
+321 -136
View File
@@ -1,5 +1,7 @@
"""Tool instance API endpoints.""" """Tool instance API endpoints."""
import asyncio
import glob as glob_module
import logging import logging
import os import os
import subprocess import subprocess
@@ -7,23 +9,39 @@ import uuid
from datetime import datetime from datetime import datetime
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi import (
from fastapi.responses import StreamingResponse APIRouter,
APIRouter as FastAPIRouter,
Depends,
HTTPException,
Request,
Response,
status,
)
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__) from src.auth.dependencies import (
_get_owned_project,
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session _get_user,
get_current_user_id,
get_db_session,
)
from src.models.config_profile import ConfigProfile
from src.models.git_repository import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey from src.models.ssh_key import SSHKey
from src.models.config_profile import ConfigProfile
from src.models.tool_config import ToolConfig from src.models.tool_config import ToolConfig
from src.models.tool_instance import ToolInstance from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType from src.models.tool_type import ToolType
from src.models.user import User from src.services.clone import check_dirty_state, clone_repository
from src.services.config_profile_resolver import (
ConfigProfileCycleError,
ResolvedProfile,
apply_resolved_profile,
resolve_profile,
)
from src.services.docker import ( from src.services.docker import (
check_tunnel_health, check_tunnel_health,
connect_container_to_network, connect_container_to_network,
@@ -43,19 +61,12 @@ from src.services.docker import (
write_config_files, write_config_files,
write_env_file, write_env_file,
) )
from src.services.clone import check_dirty_state, clone_repository, remove_clone_directory
from src.services.docker_build import build_image from src.services.docker_build import build_image
from src.services.config_profile_resolver import (
apply_resolved_profile,
resolve_profile,
ConfigProfileCycleError,
ResolvedProfile,
)
from src.services.readiness_probe import execute_probe from src.services.readiness_probe import execute_probe
from src.services.ssh_keys import prepare_ssh_key_files, cleanup_ssh_key_files from src.services.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files
logger = logging.getLogger(__name__)
import asyncio
import glob as glob_module
async def _resolve_git_mounts( async def _resolve_git_mounts(
session: AsyncSession, session: AsyncSession,
@@ -75,7 +86,11 @@ async def _resolve_git_mounts(
# Process all git mounts concurrently # Process all git mounts concurrently
tasks = [] tasks = []
for git_mount in resolved.git_mounts: for git_mount in resolved.git_mounts:
tasks.append(_resolve_single_git_mount(session, git_mount, instance_dir, working_directory)) tasks.append(
_resolve_single_git_mount(
session, git_mount, instance_dir, working_directory
)
)
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -116,7 +131,7 @@ async def _resolve_single_git_mount(
logger.warning( logger.warning(
"Git mount skipped: target_path '%s' is relative but no working_directory is configured. " "Git mount skipped: target_path '%s' is relative but no working_directory is configured. "
"Set working_directory in the tool config or use an absolute path.", "Set working_directory in the tool config or use an absolute path.",
target_path target_path,
) )
return [] return []
target_path = os.path.join(working_directory, target_path) target_path = os.path.join(working_directory, target_path)
@@ -128,6 +143,7 @@ async def _resolve_single_git_mount(
# Generate a unique directory name from the URL # Generate a unique directory name from the URL
import hashlib import hashlib
url_hash = hashlib.md5(remote_url.encode()).hexdigest()[:12] url_hash = hashlib.md5(remote_url.encode()).hexdigest()[:12]
repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo" repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo"
clone_parent = os.path.join(instance_dir, "git-mounts", f"{repo_name}-{url_hash}") clone_parent = os.path.join(instance_dir, "git-mounts", f"{repo_name}-{url_hash}")
@@ -164,8 +180,7 @@ async def _resolve_single_git_mount(
logger.debug("Checked out branch %s for %s", branch, remote_url) logger.debug("Checked out branch %s for %s", branch, remote_url)
else: else:
logger.warning( logger.warning(
"Branch %s not found in %s, using current branch", "Branch %s not found in %s, using current branch", branch, remote_url
branch, remote_url
) )
# Build source path and expand globs # Build source path and expand globs
@@ -178,7 +193,11 @@ async def _resolve_single_git_mount(
matched_paths = _expand_glob_source(source_full, repo_path) matched_paths = _expand_glob_source(source_full, repo_path)
if not matched_paths: if not matched_paths:
logger.warning("Git mount skipped: no files matched source path %s in %s", source_path, remote_url) logger.warning(
"Git mount skipped: no files matched source path %s in %s",
source_path,
remote_url,
)
return [] return []
volume_mounts = [] volume_mounts = []
@@ -195,12 +214,19 @@ async def _resolve_single_git_mount(
rel_path = os.path.relpath(matched_path, repo_path) rel_path = os.path.relpath(matched_path, repo_path)
final_target = os.path.join(target_path, rel_path) final_target = os.path.join(target_path, rel_path)
volume_mounts.append({ volume_mounts.append(
"source": matched_path, {
"target": final_target, "source": matched_path,
"type": "bind", "target": final_target,
}) "type": "bind",
logger.debug("Added git mount: %s -> %s (url: %s)", matched_path, final_target, remote_url) }
)
logger.debug(
"Added git mount: %s -> %s (url: %s)",
matched_path,
final_target,
remote_url,
)
return volume_mounts return volume_mounts
@@ -234,7 +260,12 @@ def _checkout_branch(repo_path: str, branch: str) -> bool:
) )
if result.returncode != 0: if result.returncode != 0:
logger.warning("Failed to checkout branch %s in %s: %s", branch, repo_path, result.stderr.strip()) logger.warning(
"Failed to checkout branch %s in %s: %s",
branch,
repo_path,
result.stderr.strip(),
)
return False return False
return True return True
@@ -293,7 +324,11 @@ def _expand_glob_source(source_path: str, repo_path: str) -> list[str]:
if abs_path.startswith(os.path.abspath(repo_path)): if abs_path.startswith(os.path.abspath(repo_path)):
results.append(abs_path) results.append(abs_path)
if len(results) >= MAX_GLOB_MATCHES: if len(results) >= MAX_GLOB_MATCHES:
logger.warning("Glob pattern matched %d files, limited to %d", total_matched, MAX_GLOB_MATCHES) logger.warning(
"Glob pattern matched %d files, limited to %d",
total_matched,
MAX_GLOB_MATCHES,
)
break break
return results return results
@@ -308,11 +343,21 @@ class CreateInstanceRequest(BaseModel):
model_config = {"extra": "ignore"} model_config = {"extra": "ignore"}
tool_type_id: str = Field(description="UUID of the tool type to instantiate") 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") display_name: str | None = Field(
clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'") default=None, description="Optional display name for the instance"
branch: str | None = Field(default="main", description="Branch to clone (when clone_mode='clone')") )
new_branch: str | None = Field(default=None, description="Create a new local branch after cloning") clone_mode: str = Field(
config_profile_id: str | None = Field(default=None, description="Optional config profile ID for launch") default="mount", description="Repository access mode: 'mount' or 'clone'"
)
branch: str | None = Field(
default="main", description="Branch to clone (when clone_mode='clone')"
)
new_branch: str | None = Field(
default=None, description="Create a new local branch after cloning"
)
config_profile_id: str | None = Field(
default=None, description="Optional config profile ID for launch"
)
class StartInstanceRequest(BaseModel): class StartInstanceRequest(BaseModel):
@@ -320,7 +365,9 @@ class StartInstanceRequest(BaseModel):
model_config = {"extra": "ignore"} model_config = {"extra": "ignore"}
config_profile_id: str | None = Field(default=None, description="Config profile ID to apply, or null for none") config_profile_id: str | None = Field(
default=None, description="Config profile ID to apply, or null for none"
)
async def _validate_config_profile( async def _validate_config_profile(
@@ -538,17 +585,19 @@ async def create_instance(
if not repo.remote_url: if not repo.remote_url:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="repository does not have a remote URL for cloning" detail="repository does not have a remote URL for cloning",
) )
if not repo.ssh_key_id: if not repo.ssh_key_id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="repository must have an SSH key assigned for clone mode" detail="repository must have an SSH key assigned for clone mode",
) )
# Generate unique name # Generate unique name
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}" instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}" instance_display = (
data.display_name or f"{tool_type.display_name} - {repo.name}"
)
# Create instance directory # Create instance directory
instance_dir = ensure_instance_directory(instance_name) instance_dir = ensure_instance_directory(instance_name)
@@ -564,7 +613,7 @@ async def create_instance(
if ssh_key is None: if ssh_key is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="repository SSH key not found" detail="repository SSH key not found",
) )
# Prepare SSH key for clone operation # Prepare SSH key for clone operation
@@ -586,7 +635,7 @@ async def create_instance(
cleanup_ssh_key_files(instance_dir) cleanup_ssh_key_files(instance_dir)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to clone repository: {exc}" detail=f"Failed to clone repository: {exc}",
) )
else: else:
repo_path = repo.path repo_path = repo.path
@@ -595,15 +644,21 @@ async def create_instance(
if data.clone_mode == "clone" and repo_path: if data.clone_mode == "clone" and repo_path:
try: try:
repo_contents = os.listdir(repo_path) repo_contents = os.listdir(repo_path)
if not repo_contents or (len(repo_contents) == 1 and repo_contents[0] == ".git"): if not repo_contents or (
len(repo_contents) == 1 and repo_contents[0] == ".git"
):
logger.error("Cloned repository at %s appears empty", repo_path) logger.error("Cloned repository at %s appears empty", repo_path)
raise RuntimeError("Cloned repository is empty") raise RuntimeError("Cloned repository is empty")
logger.debug("Verified cloned repo at %s has %d items", repo_path, len(repo_contents)) logger.debug(
"Verified cloned repo at %s has %d items",
repo_path,
len(repo_contents),
)
except Exception as exc: except Exception as exc:
logger.exception("Failed to verify cloned repository: %s", exc) logger.exception("Failed to verify cloned repository: %s", exc)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Cloned repository verification failed: {exc}" detail=f"Cloned repository verification failed: {exc}",
) )
# Create new local branch if requested # Create new local branch if requested
@@ -615,14 +670,18 @@ async def create_instance(
text=True, text=True,
) )
if result.returncode != 0: if result.returncode != 0:
logger.error("Failed to create branch %s: %s", data.new_branch, result.stderr) logger.error(
"Failed to create branch %s: %s", data.new_branch, result.stderr
)
raise RuntimeError(f"Failed to create branch: {result.stderr}") raise RuntimeError(f"Failed to create branch: {result.stderr}")
logger.debug("Created local branch %s in cloned repository", data.new_branch) logger.debug(
"Created local branch %s in cloned repository", data.new_branch
)
except Exception as exc: except Exception as exc:
logger.exception("Failed to create local branch: %s", exc) logger.exception("Failed to create local branch: %s", exc)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create local branch: {exc}" detail=f"Failed to create local branch: {exc}",
) )
# Handle based on definition type # Handle based on definition type
@@ -640,25 +699,39 @@ async def create_instance(
) )
if returncode != 0: if returncode != 0:
logger.error("Failed to build image for instance %s: %s", instance_name, stderr) logger.error(
"Failed to build image for instance %s: %s",
instance_name,
stderr,
)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to build Docker image: {stderr[:500]}", detail=f"Failed to build Docker image: {stderr[:500]}",
) )
logger.info("Successfully built image %s for instance %s", image_tag, instance_name) logger.info(
"Successfully built image %s for instance %s",
image_tag,
instance_name,
)
# Generate compose for dockerfile-built image # Generate compose for dockerfile-built image
# Only include ports if tool requires one (skip for terminal-only tools) # Only include ports if tool requires one (skip for terminal-only tools)
ports_section = f""" ports: ports_section = (
f""" ports:
- "{tool_port}:{tool_type.default_port}" - "{tool_port}:{tool_type.default_port}"
""" if tool_type.default_port and tool_type.default_port > 0 else "" """
if tool_type.default_port and tool_type.default_port > 0
else ""
)
compose_content = f"""version: "3.8" compose_content = f"""version: "3.8"
services: services:
app: app:
image: {image_tag} image: {image_tag}
container_name: {instance_name.lower()} container_name: {instance_name.lower()}
stdin_open: true
tty: true
{ports_section} volumes: {ports_section} volumes:
- {repo_path}:/workspace - {repo_path}:/workspace
restart: unless-stopped restart: unless-stopped
@@ -676,11 +749,14 @@ services:
"USER_ID": str(user_id), "USER_ID": str(user_id),
"PROJECT_ID": str(project_id), "PROJECT_ID": str(project_id),
} }
compose_content = render_compose_template(tool_type.compose_template, variables) compose_content = render_compose_template(
tool_type.compose_template, variables
)
# Safety check: for clone mode, ensure repo is mounted in compose file # Safety check: for clone mode, ensure repo is mounted in compose file
if data.clone_mode == "clone" and repo_path: if data.clone_mode == "clone" and repo_path:
import yaml import yaml
compose_data = yaml.safe_load(compose_content) compose_data = yaml.safe_load(compose_content)
repo_mounted = False repo_mounted = False
if compose_data and "services" in compose_data: if compose_data and "services" in compose_data:
@@ -706,7 +782,9 @@ services:
svc["volumes"] = [] svc["volumes"] = []
svc["volumes"].append(f"{repo_path}:/workspace") svc["volumes"].append(f"{repo_path}:/workspace")
break break
compose_content = yaml.dump(compose_data, default_flow_style=False) compose_content = yaml.dump(
compose_data, default_flow_style=False
)
write_compose_file(instance_dir, compose_content) write_compose_file(instance_dir, compose_content)
@@ -722,7 +800,9 @@ services:
compose_path=compose_path, compose_path=compose_path,
port=tool_port, port=tool_port,
clone_mode=data.clone_mode, clone_mode=data.clone_mode,
branch=data.new_branch if data.new_branch else (data.branch if data.clone_mode == "clone" else None), branch=data.new_branch
if data.new_branch
else (data.branch if data.clone_mode == "clone" else None),
selected_config_profile_id=selected_profile_id, selected_config_profile_id=selected_profile_id,
) )
session.add(instance) session.add(instance)
@@ -737,7 +817,9 @@ services:
"status": instance.status, "status": instance.status,
"clone_mode": instance.clone_mode, "clone_mode": instance.clone_mode,
"branch": instance.branch, "branch": instance.branch,
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None, "selected_config_profile_id": str(instance.selected_config_profile_id)
if instance.selected_config_profile_id
else None,
"created_at": instance.created_at.isoformat(), "created_at": instance.created_at.isoformat(),
} }
except Exception as exc: except Exception as exc:
@@ -790,20 +872,22 @@ async def list_instances(
instances_data = [] instances_data = []
for i in instances: for i in instances:
tool_type = await session.get(ToolType, i.tool_type_id) tool_type = await session.get(ToolType, i.tool_type_id)
instances_data.append({ instances_data.append(
"id": str(i.id), {
"name": i.name, "id": str(i.id),
"display_name": i.display_name, "name": i.name,
"tool_type_id": str(i.tool_type_id), "display_name": i.display_name,
"tool_type_name": tool_type.name if tool_type else "unknown", "tool_type_id": str(i.tool_type_id),
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [], "tool_type_name": tool_type.name if tool_type else "unknown",
"status": i.status, "tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
"url": i.url, "status": i.status,
"port": i.port, "url": i.url,
"clone_mode": i.clone_mode, "port": i.port,
"branch": i.branch, "clone_mode": i.clone_mode,
"created_at": i.created_at.isoformat(), "branch": i.branch,
}) "created_at": i.created_at.isoformat(),
}
)
return {"instances": instances_data} return {"instances": instances_data}
@@ -864,9 +948,15 @@ async def get_instance(
"port": instance.port, "port": instance.port,
"clone_mode": instance.clone_mode, "clone_mode": instance.clone_mode,
"branch": instance.branch, "branch": instance.branch,
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None, "selected_config_profile_id": str(instance.selected_config_profile_id)
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None, if instance.selected_config_profile_id
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None, else None,
"last_started_at": instance.last_started_at.isoformat()
if instance.last_started_at
else None,
"last_stopped_at": instance.last_stopped_at.isoformat()
if instance.last_stopped_at
else None,
"created_at": instance.created_at.isoformat(), "created_at": instance.created_at.isoformat(),
} }
@@ -932,11 +1022,15 @@ async def start_instance(
extra_env_vars = {} extra_env_vars = {}
extra_volumes = [] extra_volumes = []
config_query = select(ToolConfig).where( config_query = (
ToolConfig.user_id == user_id, select(ToolConfig)
ToolConfig.tool_type_id == instance.tool_type_id, .where(
).where( ToolConfig.user_id == user_id,
(ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None)) ToolConfig.tool_type_id == instance.tool_type_id,
)
.where(
(ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None))
)
) )
config_result = await session.execute(config_query) config_result = await session.execute(config_query)
@@ -968,9 +1062,11 @@ async def start_instance(
instance_dir = os.path.dirname(instance.compose_path) instance_dir = os.path.dirname(instance.compose_path)
if instance.selected_config_profile_id is not None: if instance.selected_config_profile_id is not None:
try: try:
resolved = await resolve_profile(session, instance.selected_config_profile_id) resolved = await resolve_profile(
profile_env, profile_files, profile_mounts, profile_hints = apply_resolved_profile( session, instance.selected_config_profile_id
instance_dir, resolved )
profile_env, profile_files, profile_mounts, profile_hints = (
apply_resolved_profile(instance_dir, resolved)
) )
# Profile env vars override tool config env vars # Profile env vars override tool config env vars
env_vars.update(profile_env) env_vars.update(profile_env)
@@ -979,7 +1075,9 @@ async def start_instance(
# Profile mounts are added to extra volumes # Profile mounts are added to extra volumes
extra_volumes.extend(profile_mounts) extra_volumes.extend(profile_mounts)
# Git repository mounts are resolved and added # Git repository mounts are resolved and added
git_mount_volumes = await _resolve_git_mounts(session, resolved, instance_dir, working_directory) git_mount_volumes = await _resolve_git_mounts(
session, resolved, instance_dir, working_directory
)
extra_volumes.extend(git_mount_volumes) extra_volumes.extend(git_mount_volumes)
# Profile runtime hints override tool config values # Profile runtime hints override tool config values
if profile_hints.get("start_command"): if profile_hints.get("start_command"):
@@ -998,7 +1096,9 @@ async def start_instance(
len(git_mount_volumes), len(git_mount_volumes),
) )
except ConfigProfileCycleError as exc: except ConfigProfileCycleError as exc:
logger.error("Cycle detected in config profile for instance %s: %s", instance.id, exc) logger.error(
"Cycle detected in config profile for instance %s: %s", instance.id, exc
)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Config profile cycle detected: {exc}", detail=f"Config profile cycle detected: {exc}",
@@ -1015,7 +1115,9 @@ async def start_instance(
if config_files: if config_files:
write_config_files(instance_dir, config_files) write_config_files(instance_dir, config_files)
logger.debug("Wrote %d config files for instance %s", len(config_files), instance.id) logger.debug(
"Wrote %d config files for instance %s", len(config_files), instance.id
)
# Mount SSH key for clone-mode instances # Mount SSH key for clone-mode instances
if instance.clone_mode == "clone": if instance.clone_mode == "clone":
@@ -1025,30 +1127,53 @@ async def start_instance(
if ssh_key: if ssh_key:
try: try:
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key) ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
extra_volumes.append({ extra_volumes.append(
"source": ssh_dir, {
"target": "/root/.ssh", "source": ssh_dir,
"type": "ro", "target": "/root/.ssh",
}) "type": "ro",
logger.debug("Mounted SSH key for clone-mode instance %s", instance.id) }
)
logger.debug(
"Mounted SSH key for clone-mode instance %s", instance.id
)
except Exception as exc: except Exception as exc:
logger.error("Failed to prepare SSH key for instance %s: %s", instance.id, exc) logger.error(
"Failed to prepare SSH key for instance %s: %s",
instance.id,
exc,
)
# Modify compose file if needed (port override, start command, working dir, volumes) # Modify compose file if needed (port override, start command, working dir, volumes)
if port_override or start_command or working_directory or extra_volumes: if port_override or start_command or working_directory or extra_volumes:
_modify_compose_file(instance.compose_path, port_override, start_command, working_directory, extra_volumes) _modify_compose_file(
instance.compose_path,
port_override,
start_command,
working_directory,
extra_volumes,
)
logger.debug("Modified compose file for instance %s", instance.id) logger.debug("Modified compose file for instance %s", instance.id)
# Sanitize compose file to remove invalid port mappings from old instances # Sanitize compose file to remove invalid port mappings from old instances
_sanitize_compose_file(instance.compose_path) _sanitize_compose_file(instance.compose_path)
# Execute docker compose up with env file # Execute docker compose up with env file
logger.debug("Running docker compose up for instance %s (compose_path=%s)", instance.id, instance.compose_path) logger.debug(
"Running docker compose up for instance %s (compose_path=%s)",
instance.id,
instance.compose_path,
)
returncode, stdout, stderr = execute_compose_command( returncode, stdout, stderr = execute_compose_command(
instance.compose_path, "up", env_file=env_file_path instance.compose_path, "up", env_file=env_file_path
) )
logger.debug("Docker compose up completed for instance %s: returncode=%d, stdout=%s, stderr=%s", logger.debug(
instance.id, returncode, stdout[:200] if stdout else "", stderr[:500] if stderr else "") "Docker compose up completed for instance %s: returncode=%d, stdout=%s, stderr=%s",
instance.id,
returncode,
stdout[:200] if stdout else "",
stderr[:500] if stderr else "",
)
if returncode != 0: if returncode != 0:
instance.status = "error" instance.status = "error"
@@ -1085,7 +1210,9 @@ async def start_instance(
await session.commit() await session.commit()
logger.debug("Instance %s: verifying container startup...", instance.id) logger.debug("Instance %s: verifying container startup...", instance.id)
startup_result = wait_for_container_running(instance.container_id, timeout=30, interval=2.0) startup_result = wait_for_container_running(
instance.container_id, timeout=30, interval=2.0
)
if not startup_result["success"]: if not startup_result["success"]:
# Container failed to start # Container failed to start
@@ -1141,7 +1268,10 @@ async def start_instance(
await session.commit() await session.commit()
logger.debug( logger.debug(
"Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d", "Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d",
instance.id, probe_command, probe_timeout, probe_interval instance.id,
probe_command,
probe_timeout,
probe_interval,
) )
success, probe_logs = await execute_probe( success, probe_logs = await execute_probe(
@@ -1192,15 +1322,24 @@ async def start_instance(
} }
instance_port = tool_type.default_port or 0 instance_port = tool_type.default_port or 0
logger.debug("Tool type for instance %s: name=%s, default_port=%s, interface_type=%s", logger.debug(
instance.id, tool_type.name, instance_port, tool_type.interface_type) "Tool type for instance %s: name=%s, default_port=%s, interface_type=%s",
instance.id,
tool_type.name,
instance_port,
tool_type.interface_type,
)
# Only create Cloudflare tunnel for web-enabled tools # Only create Cloudflare tunnel for web-enabled tools
if tool_type.interface_type == "web": if tool_type.interface_type == "web":
# Create temporary Cloudflare tunnel for public access # Create temporary Cloudflare tunnel for public access
try: try:
logger.debug("Creating temporary tunnel for instance %s (container=%s, port=%d)", logger.debug(
instance.id, instance.container_name, instance_port) "Creating temporary tunnel for instance %s (container=%s, port=%d)",
instance.id,
instance.container_name,
instance_port,
)
tunnel_info = start_cloudflared_tunnel( tunnel_info = start_cloudflared_tunnel(
container_name=instance.container_name or instance.name, container_name=instance.container_name or instance.name,
port=instance_port, port=instance_port,
@@ -1217,6 +1356,7 @@ async def start_instance(
) )
except Exception as exc: except Exception as exc:
import traceback import traceback
error_msg = str(exc) error_msg = str(exc)
error_trace = traceback.format_exc() error_trace = traceback.format_exc()
logger.error( logger.error(
@@ -1234,7 +1374,10 @@ async def start_instance(
} }
else: else:
# Terminal-only tool - no tunnel needed # Terminal-only tool - no tunnel needed
logger.info("Instance %s is terminal-only (no web interface), skipping tunnel creation", instance.id) logger.info(
"Instance %s is terminal-only (no web interface), skipping tunnel creation",
instance.id,
)
instance.url = None instance.url = None
instance.public_url = None instance.public_url = None
await session.commit() await session.commit()
@@ -1279,9 +1422,15 @@ async def stop_instance(
if instance.tunnel_id: if instance.tunnel_id:
try: try:
stop_cloudflared_tunnel(instance.tunnel_id) stop_cloudflared_tunnel(instance.tunnel_id)
logger.debug("Stopped tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id) logger.debug(
"Stopped tunnel for instance %s (pid=%s)",
instance.id,
instance.tunnel_id,
)
except Exception as exc: except Exception as exc:
logger.warning("Failed to stop tunnel for instance %s: %s", instance.id, exc) logger.warning(
"Failed to stop tunnel for instance %s: %s", instance.id, exc
)
if instance.compose_path and os.path.exists(instance.compose_path): if instance.compose_path and os.path.exists(instance.compose_path):
execute_compose_command(instance.compose_path, "stop") execute_compose_command(instance.compose_path, "stop")
@@ -1333,18 +1482,26 @@ async def restart_instance(
if instance.tunnel_id: if instance.tunnel_id:
try: try:
stop_cloudflared_tunnel(instance.tunnel_id) stop_cloudflared_tunnel(instance.tunnel_id)
logger.debug("Stopped old tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id) logger.debug(
"Stopped old tunnel for instance %s (pid=%s)",
instance.id,
instance.tunnel_id,
)
except Exception as exc: except Exception as exc:
logger.warning("Failed to stop old tunnel for instance %s: %s", instance.id, exc) logger.warning(
"Failed to stop old tunnel for instance %s: %s", instance.id, exc
)
# Re-apply stored config profile on restart # Re-apply stored config profile on restart
if instance.compose_path and os.path.exists(instance.compose_path): if instance.compose_path and os.path.exists(instance.compose_path):
instance_dir = os.path.dirname(instance.compose_path) instance_dir = os.path.dirname(instance.compose_path)
if instance.selected_config_profile_id is not None: if instance.selected_config_profile_id is not None:
try: try:
resolved = await resolve_profile(session, instance.selected_config_profile_id) resolved = await resolve_profile(
profile_env, profile_files, profile_mounts, profile_hints = apply_resolved_profile( session, instance.selected_config_profile_id
instance_dir, resolved )
profile_env, profile_files, profile_mounts, profile_hints = (
apply_resolved_profile(instance_dir, resolved)
) )
# Write env file with resolved profile env vars # Write env file with resolved profile env vars
if profile_env: if profile_env:
@@ -1372,8 +1529,10 @@ async def restart_instance(
# Get tool type for default port # Get tool type for default port
tool_type = await session.get(ToolType, instance.tool_type_id) tool_type = await session.get(ToolType, instance.tool_type_id)
if not tool_type or not tool_type.default_port: if not tool_type or not tool_type.default_port:
logger.error("Tool type %s has no default_port configured. Cannot create tunnel.", logger.error(
instance.tool_type_id) "Tool type %s has no default_port configured. Cannot create tunnel.",
instance.tool_type_id,
)
instance.status = "error" instance.status = "error"
await session.commit() await session.commit()
return { return {
@@ -1461,7 +1620,9 @@ async def delete_instance(
# Check dirty state for clone-mode instances # Check dirty state for clone-mode instances
if instance.clone_mode == "clone" and not force: if instance.clone_mode == "clone" and not force:
instance_dir = os.path.dirname(instance.compose_path) if instance.compose_path else None instance_dir = (
os.path.dirname(instance.compose_path) if instance.compose_path else None
)
if instance_dir: if instance_dir:
clone_path = os.path.join(instance_dir, "repo-clone") clone_path = os.path.join(instance_dir, "repo-clone")
if os.path.exists(clone_path): if os.path.exists(clone_path):
@@ -1480,9 +1641,15 @@ async def delete_instance(
if instance.tunnel_id: if instance.tunnel_id:
try: try:
stop_cloudflared_tunnel(instance.tunnel_id) stop_cloudflared_tunnel(instance.tunnel_id)
logger.debug("Stopped tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id) logger.debug(
"Stopped tunnel for instance %s (pid=%s)",
instance.id,
instance.tunnel_id,
)
except Exception as exc: except Exception as exc:
logger.warning("Failed to stop tunnel for instance %s: %s", instance.id, exc) logger.warning(
"Failed to stop tunnel for instance %s: %s", instance.id, exc
)
# Stop and remove container # Stop and remove container
if instance.compose_path and os.path.exists(instance.compose_path): if instance.compose_path and os.path.exists(instance.compose_path):
@@ -1493,6 +1660,7 @@ async def delete_instance(
instance_dir = os.path.dirname(instance.compose_path) instance_dir = os.path.dirname(instance.compose_path)
if os.path.exists(instance_dir): if os.path.exists(instance_dir):
import shutil import shutil
shutil.rmtree(instance_dir) shutil.rmtree(instance_dir)
await session.delete(instance) await session.delete(instance)
@@ -1589,11 +1757,17 @@ async def recreate_tunnel_endpoint(
detail=f"Tunnel is working but application returned HTTP {tunnel_health.get('status_code')}. Recreating the tunnel will not fix this issue.", detail=f"Tunnel is working but application returned HTTP {tunnel_health.get('status_code')}. Recreating the tunnel will not fix this issue.",
) )
elif tunnel_health["tunnel_status"] == "healthy": elif tunnel_health["tunnel_status"] == "healthy":
return {"status": "healthy", "url": instance.url, "message": "Tunnel is already healthy"} return {
"status": "healthy",
"url": instance.url,
"message": "Tunnel is already healthy",
}
# Get tool type for default port # Get tool type for default port
tool_type = await session.get(ToolType, instance.tool_type_id) tool_type = await session.get(ToolType, instance.tool_type_id)
instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080 instance_port = (
tool_type.default_port if tool_type and tool_type.default_port else 8080
)
try: try:
tunnel_info = recreate_tunnel( tunnel_info = recreate_tunnel(
@@ -1674,7 +1848,9 @@ async def check_instance_tunnel_health(
if instance.status == "probing": if instance.status == "probing":
response["probe_status"] = "pending" response["probe_status"] = "pending"
elif instance.probe_result: elif instance.probe_result:
response["probe_status"] = "success" if instance.probe_result.get("success") else "failed" response["probe_status"] = (
"success" if instance.probe_result.get("success") else "failed"
)
response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", [])) response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))
# Check tunnel health if instance has a URL and is web-enabled # Check tunnel health if instance has a URL and is web-enabled
@@ -1833,10 +2009,9 @@ async def proxy_to_instance(
) )
from fastapi import APIRouter as FastAPIRouter
sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"]) sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"])
@sessions_router.get( @sessions_router.get(
"/me/sessions", "/me/sessions",
summary="Get user sessions", summary="Get user sessions",
@@ -1860,7 +2035,11 @@ async def get_user_sessions(
result = await session.execute( result = await session.execute(
select(ToolInstance) select(ToolInstance)
.where(ToolInstance.owner_id == user_id) .where(ToolInstance.owner_id == user_id)
.where(ToolInstance.status.in_(["running", "building", "pending", "stopped", "error"])) .where(
ToolInstance.status.in_(
["running", "building", "pending", "stopped", "error"]
)
)
.order_by(ToolInstance.created_at.desc()) .order_by(ToolInstance.created_at.desc())
) )
instances = result.scalars().all() instances = result.scalars().all()
@@ -1871,22 +2050,28 @@ async def get_user_sessions(
repo = await session.get(GitRepository, instance.repository_id) repo = await session.get(GitRepository, instance.repository_id)
project = await session.get(Project, instance.project_id) project = await session.get(Project, instance.project_id)
sessions.append({ sessions.append(
"id": str(instance.id), {
"display_name": instance.display_name, "id": str(instance.id),
"tool_type_name": tool_type.name if tool_type else "unknown", "display_name": instance.display_name,
"tool_icon": tool_type.name if tool_type else "code", "tool_type_name": tool_type.name if tool_type else "unknown",
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [], "tool_icon": tool_type.name if tool_type else "code",
"repository_name": repo.name if repo else "unknown", "tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
"repository_id": str(instance.repository_id), "repository_name": repo.name if repo else "unknown",
"project_name": project.name if project else "unknown", "repository_id": str(instance.repository_id),
"project_id": str(instance.project_id), "project_name": project.name if project else "unknown",
"status": instance.status, "project_id": str(instance.project_id),
"url": instance.url, "status": instance.status,
"clone_mode": instance.clone_mode, "url": instance.url,
"branch": instance.branch, "clone_mode": instance.clone_mode,
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None, "branch": instance.branch,
"created_at": instance.created_at.isoformat() if instance.created_at else None, "selected_config_profile_id": str(instance.selected_config_profile_id)
}) if instance.selected_config_profile_id
else None,
"created_at": instance.created_at.isoformat()
if instance.created_at
else None,
}
)
return {"sessions": sessions} return {"sessions": sessions}
-2
View File
@@ -1,7 +1,6 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
import yaml
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, field_validator, model_validator from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import select from sqlalchemy import select
@@ -9,7 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.api.tool_types_validation import ( from src.api.tool_types_validation import (
check_port_exposed, check_port_exposed,
sanitize_template_vars,
validate_compose_yaml, validate_compose_yaml,
validate_required_variables, validate_required_variables,
) )
+3 -4
View File
@@ -1,17 +1,16 @@
import logging import logging
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends
logger = logging.getLogger(__name__)
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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_user, get_current_user_id, get_db_session
from src.models.user import User
from src.models.user_config import UserConfig from src.models.user_config import UserConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/users/me", tags=["user-config"]) router = APIRouter(prefix="/users/me", tags=["user-config"])
+1
View File
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.session import decode_session_cookie from src.auth.session import decode_session_cookie
from src.config import Settings from src.config import Settings
from src.database import SessionLocal from src.database import SessionLocal
from src.models.project import Project
from src.models.user import User from src.models.user import User
-2
View File
@@ -1,4 +1,3 @@
import json
import logging import logging
import os import os
@@ -7,7 +6,6 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from src.api.auth import router as auth_router from src.api.auth import router as auth_router
from src.api.dashboard import router as dashboard_router from src.api.dashboard import router as dashboard_router
+8 -10
View File
@@ -1,7 +1,9 @@
"""Docker service for managing tool instances.""" """Docker service for managing tool instances."""
import os import os
import re
import subprocess import subprocess
import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -137,6 +139,8 @@ def execute_compose_command(
def get_container_id(instance_name: str) -> str | None: def get_container_id(instance_name: str) -> str | None:
"""Get the container ID for a compose service. """Get the container ID for a compose service.
Searches all containers including stopped/exited ones.
Args: Args:
instance_name: The service name in compose instance_name: The service name in compose
@@ -145,7 +149,7 @@ def get_container_id(instance_name: str) -> str | None:
""" """
# Docker container names are lowercase internally; normalize to ensure match # Docker container names are lowercase internally; normalize to ensure match
result = subprocess.run( result = subprocess.run(
["docker", "ps", "-q", "--filter", f"name={instance_name.lower()}"], ["docker", "ps", "-a", "-q", "--filter", f"name={instance_name.lower()}"],
capture_output=True, capture_output=True,
text=True, text=True,
) )
@@ -158,6 +162,8 @@ def get_container_id(instance_name: str) -> str | None:
def get_container_name(instance_name: str) -> str | None: def get_container_name(instance_name: str) -> str | None:
"""Get the full container name for a compose service. """Get the full container name for a compose service.
Searches all containers including stopped/exited ones.
Args: Args:
instance_name: The service name in compose instance_name: The service name in compose
@@ -169,6 +175,7 @@ def get_container_name(instance_name: str) -> str | None:
[ [
"docker", "docker",
"ps", "ps",
"-a",
"--format", "--format",
"{{.Names}}", "{{.Names}}",
"--filter", "--filter",
@@ -252,7 +259,6 @@ def wait_for_container_running(
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None), Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
and 'waited_seconds' (float) and 'waited_seconds' (float)
""" """
import time
start_time = time.time() start_time = time.time()
@@ -336,11 +342,6 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
raise RuntimeError(f"No free port found in range {start}-{end}") raise RuntimeError(f"No free port found in range {start}-{end}")
import subprocess
import time
import re
def start_cloudflared_tunnel( def start_cloudflared_tunnel(
container_name: str, port: int, timeout: int = 30 container_name: str, port: int, timeout: int = 30
) -> dict[str, str]: ) -> dict[str, str]:
@@ -358,8 +359,6 @@ def start_cloudflared_tunnel(
Dict with 'url' (the public tunnel URL) and 'pid' (process ID) Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
""" """
import subprocess import subprocess
import time
import re
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -441,7 +440,6 @@ def stop_cloudflared_tunnel(pid: str) -> None:
Args: Args:
pid: Process ID of the cloudflared tunnel pid: Process ID of the cloudflared tunnel
""" """
import os
import signal import signal
try: try:
-1
View File
@@ -18,7 +18,6 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
Returns: Returns:
Tuple of (returncode, stdout, stderr) Tuple of (returncode, stdout, stderr)
""" """
import os
from pathlib import Path from pathlib import Path
# Write Dockerfile # Write Dockerfile
@@ -3,7 +3,6 @@
import asyncio import asyncio
import logging import logging
import uuid import uuid
from typing import Any
from fastapi import WebSocket from fastapi import WebSocket
-1
View File
@@ -2,7 +2,6 @@
import subprocess import subprocess
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any
def _run_git_command(repo_path: str, *args: str) -> str: def _run_git_command(repo_path: str, *args: str) -> str:
+1 -3
View File
@@ -8,16 +8,14 @@ from unittest.mock import patch
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker
# Set test environment BEFORE importing app modules # Set test environment BEFORE importing app modules
os.environ["APP_ENV"] = "testing" os.environ["APP_ENV"] = "testing"
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production" os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
from src.config import Settings, build_database_url from src.config import Settings
from src.models.base import Base from src.models.base import Base
from src.main import app from src.main import app
from src.auth.dependencies import get_db_session from src.auth.dependencies import get_db_session
-22
View File
@@ -82,28 +82,6 @@ def test_repository_and_user_config_relationships_are_registered() -> None:
assert UserConfig.user.property.mapper.class_ is User 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.asyncio
@pytest.mark.integration @pytest.mark.integration
@@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta
import asyncio import asyncio
import pytest import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
@@ -1,4 +1,3 @@
import uuid
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -1,4 +1,3 @@
import uuid
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -221,7 +220,7 @@ class TestToolTypesAPIExtended:
}, },
) )
assert response.status_code == 422 assert response.status_code == 422
data = response.json() _ = response.json()
def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None: def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None:
"""Test creating a tool type with startup_command.""" """Test creating a tool type with startup_command."""
@@ -6,7 +6,6 @@ from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.services.config_profile_resolver import ( from src.services.config_profile_resolver import (
ConfigProfileCycleError, ConfigProfileCycleError,
ConfigProfileNotFoundError, ConfigProfileNotFoundError,
ResolvedProfile,
check_include_cycle, check_include_cycle,
resolve_profile, resolve_profile,
_merge_env_vars, _merge_env_vars,
@@ -63,7 +62,6 @@ class TestMergeFunctions:
def test_merge_mounts_basic(self) -> None: def test_merge_mounts_basic(self) -> None:
"""Test basic mount merging.""" """Test basic mount merging."""
from src.services.config_profile_resolver import ResolvedMount
result = _merge_mounts( result = _merge_mounts(
{}, {},
[{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}], [{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}],
@@ -1,7 +1,6 @@
"""Unit tests for git mount resolution in tool instances.""" """Unit tests for git mount resolution in tool instances."""
import os import os
import tempfile
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -11,7 +10,6 @@ from src.api.tool_instances import (
_expand_glob_source, _expand_glob_source,
_resolve_single_git_mount, _resolve_single_git_mount,
) )
from src.services.config_profile_resolver import ResolvedProfile
class TestExpandGlobSource: class TestExpandGlobSource:
@@ -1,6 +1,5 @@
"""Tests for git URL parsing utilities.""" """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 from src.utils.git_url_parser import extract_base_repo_url, is_valid_clone_url, parse_git_url
@@ -1,9 +1,7 @@
"""Unit tests for readiness probe service.""" """Unit tests for readiness probe service."""
import asyncio
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
from src.services.readiness_probe import execute_probe from src.services.readiness_probe import execute_probe
@@ -3,10 +3,7 @@
import os import os
import subprocess import subprocess
import tempfile import tempfile
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from src.api.tool_instances import CreateInstanceRequest from src.api.tool_instances import CreateInstanceRequest
+2 -2
View File
@@ -1,4 +1,4 @@
import axios from "axios"; import axios, { type AxiosRequestConfig } from "axios";
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
@@ -19,7 +19,7 @@ const MAX_RETRIES = 2;
const RETRY_DELAY_MS = 1000; const RETRY_DELAY_MS = 1000;
// Track retry count per request // Track retry count per request
const retryCount = new WeakMap<any, number>(); const retryCount = new WeakMap<AxiosRequestConfig, number>();
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
+7 -4
View File
@@ -1,3 +1,4 @@
import { AxiosError } from "axios";
import { apiClient } from "./client"; import { apiClient } from "./client";
export interface ToolInstance { export interface ToolInstance {
@@ -80,9 +81,10 @@ export async function startInstance(
{ config_profile_id: configProfileId } { config_profile_id: configProfileId }
); );
return response.data; return response.data;
} catch (error: any) { } catch (error) {
// Retry on network errors (e.g. Docker creating network interfaces) // Retry on network errors (e.g. Docker creating network interfaces)
if (retries > 0 && !error.response) { const axiosError = error as AxiosError;
if (retries > 0 && !axiosError.response) {
await new Promise((r) => setTimeout(r, 1500)); await new Promise((r) => setTimeout(r, 1500));
return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1); return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
} }
@@ -114,9 +116,10 @@ export async function restartInstance(
{ config_profile_id: configProfileId } { config_profile_id: configProfileId }
); );
return response.data; return response.data;
} catch (error: any) { } catch (error) {
// Retry on network errors (e.g. Docker creating network interfaces) // Retry on network errors (e.g. Docker creating network interfaces)
if (retries > 0 && !error.response) { const axiosError = error as AxiosError;
if (retries > 0 && !axiosError.response) {
await new Promise((r) => setTimeout(r, 1500)); await new Promise((r) => setTimeout(r, 1500));
return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1); return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
} }
@@ -1,5 +1,4 @@
import { useState } from "react"; import { useState } from "react";
import { Icon } from "./icon";
interface FormField { interface FormField {
name: string; name: string;
@@ -22,8 +22,6 @@ interface MobileListViewProps {
export const MobileListView: React.FC<MobileListViewProps> = ({ export const MobileListView: React.FC<MobileListViewProps> = ({
items, items,
onItemClick, onItemClick,
onItemDelete,
onItemDuplicate,
emptyMessage = "No items found", emptyMessage = "No items found",
searchPlaceholder = "Search...", searchPlaceholder = "Search...",
onSearch, onSearch,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { useMobileViewport } from "../hooks/use-mobile-viewport"; import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { extractErrorMessage } from "../utils/errors"; import { extractErrorMessage } from "../utils/errors";
-1
View File
@@ -9,7 +9,6 @@ import { listToolTypes, type ToolType } from "../api/tool_types";
import { updateUserConfig } from "../api/settings"; import { updateUserConfig } from "../api/settings";
import type { Project } from "../types"; import type { Project } from "../types";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { CreateSessionForm } from "../components/create-session-form"; import { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list"; import { SessionList } from "../components/session-list";
import { useInstanceActions } from "../hooks/use-instance-actions"; import { useInstanceActions } from "../hooks/use-instance-actions";
+3 -3
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry, type CommitHistoryResponse } from "../api/git_repositories"; import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryResponse } from "../api/git_repositories";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data"; import { useAsyncData } from "../hooks/use-async-data";
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { getProfile, updateProfile, uploadAvatar } from "../api/profile"; import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { useAuth } from "../state/auth"; import { useAuth } from "../state/auth";
import { useAsyncData } from "../hooks/use-async-data"; import { useAsyncData } from "../hooks/use-async-data";
+1 -3
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { listProjects } from "../api/projects"; import { listProjects } from "../api/projects";
import type { Project } from "../types"; import type { Project } from "../types";
@@ -11,7 +10,7 @@ import {
} from "../api/sessions"; } from "../api/sessions";
import { listToolTypes, type ToolType } from "../api/tool_types"; import { listToolTypes, type ToolType } from "../api/tool_types";
import { getUserConfig, updateUserConfig } from "../api/settings"; import { getUserConfig, updateUserConfig } from "../api/settings";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { ErrorState, LoadingState } from "../components/data-states";
import { CreateSessionForm } from "../components/create-session-form"; import { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list"; import { SessionList } from "../components/session-list";
import { SessionCard } from "../components/session-card"; import { SessionCard } from "../components/session-card";
@@ -21,7 +20,6 @@ import type { InstanceHealth } from "../api/sessions";
type SessionsStatus = "loading" | "ready" | "error"; type SessionsStatus = "loading" | "ready" | "error";
export const SessionsPage = () => { export const SessionsPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<SessionsStatus>("loading"); const [status, setStatus] = useState<SessionsStatus>("loading");
const [sessions, setSessions] = useState<Session[]>([]); const [sessions, setSessions] = useState<Session[]>([]);
const [lastSessionId, setLastSessionId] = useState<string | null>(null); const [lastSessionId, setLastSessionId] = useState<string | null>(null);
+1 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom"; import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings"; import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data"; import { useAsyncData } from "../hooks/use-async-data";
+1 -1
View File
@@ -7,7 +7,7 @@ import { useAsyncData } from "../hooks/use-async-data";
export const SSHKeysPage = () => { export const SSHKeysPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { data: keys, status, error, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []); const { data: keys, status, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
const [newKeyName, setNewKeyName] = useState(""); const [newKeyName, setNewKeyName] = useState("");
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({}); const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});