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 alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0014_merge_heads"
@@ -10,7 +10,6 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = "0015_single_interface"
@@ -1,5 +1,3 @@
import json
"""add pi agent tool type
Revision ID: 20260527_160017_add_pi_agent
@@ -7,6 +5,8 @@ Revises: f3d2dc90ba3a
Create Date: 2026-05-27T16:00:17
"""
import json
from typing import Sequence, Union
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
"""
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 alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
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.auth.dependencies import get_current_user_id, get_db_session
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.tool_type import ToolType
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.config import Settings
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.user import User
from src.utils.git_files import (
commit_file,
get_file_content,
+1 -2
View File
@@ -4,11 +4,10 @@ import time
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, status
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import text
from src.config import Settings
from src.database import SessionLocal
router = APIRouter()
-1
View File
@@ -2,7 +2,6 @@
import logging
import uuid
from typing import Any
import httpx
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.project import Project
from src.models.ssh_key import SSHKey
from src.models.user import User
router = APIRouter(prefix="/projects", tags=["projects"])
-1
View File
@@ -1,6 +1,5 @@
"""Shared Pydantic validators for API schemas."""
from typing import Any
MAX_FOLDER_SIZE_MB = 10
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.config import Settings
from src.models.ssh_key import SSHKey
from src.models.user import User
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
+1 -1
View File
@@ -4,7 +4,7 @@ import asyncio
import logging
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 src.auth.dependencies import get_db_session
+1 -1
View File
@@ -1,6 +1,5 @@
"""Tool configuration API endpoints."""
import logging
import uuid
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.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_config import ToolConfig
from src.models.tool_type import ToolType
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
+321 -136
View File
@@ -1,5 +1,7 @@
"""Tool instance API endpoints."""
import asyncio
import glob as glob_module
import logging
import os
import subprocess
@@ -7,23 +9,39 @@ import uuid
from datetime import datetime
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import StreamingResponse
from fastapi import (
APIRouter,
APIRouter as FastAPIRouter,
Depends,
HTTPException,
Request,
Response,
status,
)
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
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.models.config_profile import ConfigProfile
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.config_profile import ConfigProfile
from src.models.tool_config import ToolConfig
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.models.user import User
from src.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 (
check_tunnel_health,
connect_container_to_network,
@@ -43,19 +61,12 @@ from src.services.docker import (
write_config_files,
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.config_profile_resolver import (
apply_resolved_profile,
resolve_profile,
ConfigProfileCycleError,
ResolvedProfile,
)
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(
session: AsyncSession,
@@ -75,7 +86,11 @@ async def _resolve_git_mounts(
# Process all git mounts concurrently
tasks = []
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)
@@ -116,7 +131,7 @@ async def _resolve_single_git_mount(
logger.warning(
"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.",
target_path
target_path,
)
return []
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
import hashlib
url_hash = hashlib.md5(remote_url.encode()).hexdigest()[:12]
repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo"
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)
else:
logger.warning(
"Branch %s not found in %s, using current branch",
branch, remote_url
"Branch %s not found in %s, using current branch", branch, remote_url
)
# 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)
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 []
volume_mounts = []
@@ -195,12 +214,19 @@ async def _resolve_single_git_mount(
rel_path = os.path.relpath(matched_path, repo_path)
final_target = os.path.join(target_path, rel_path)
volume_mounts.append({
"source": matched_path,
"target": final_target,
"type": "bind",
})
logger.debug("Added git mount: %s -> %s (url: %s)", matched_path, final_target, remote_url)
volume_mounts.append(
{
"source": matched_path,
"target": final_target,
"type": "bind",
}
)
logger.debug(
"Added git mount: %s -> %s (url: %s)",
matched_path,
final_target,
remote_url,
)
return volume_mounts
@@ -234,7 +260,12 @@ def _checkout_branch(repo_path: str, branch: str) -> bool:
)
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 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)):
results.append(abs_path)
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
return results
@@ -308,11 +343,21 @@ class CreateInstanceRequest(BaseModel):
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")
clone_mode: str = Field(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")
display_name: str | None = Field(
default=None, description="Optional display name for the instance"
)
clone_mode: str = Field(
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):
@@ -320,7 +365,9 @@ class StartInstanceRequest(BaseModel):
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(
@@ -538,17 +585,19 @@ async def create_instance(
if not repo.remote_url:
raise HTTPException(
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:
raise HTTPException(
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
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
instance_dir = ensure_instance_directory(instance_name)
@@ -564,7 +613,7 @@ async def create_instance(
if ssh_key is None:
raise HTTPException(
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
@@ -586,7 +635,7 @@ async def create_instance(
cleanup_ssh_key_files(instance_dir)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to clone repository: {exc}"
detail=f"Failed to clone repository: {exc}",
)
else:
repo_path = repo.path
@@ -595,15 +644,21 @@ async def create_instance(
if data.clone_mode == "clone" and repo_path:
try:
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)
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:
logger.exception("Failed to verify cloned repository: %s", exc)
raise HTTPException(
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
@@ -615,14 +670,18 @@ async def create_instance(
text=True,
)
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}")
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:
logger.exception("Failed to create local branch: %s", exc)
raise HTTPException(
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
@@ -640,25 +699,39 @@ async def create_instance(
)
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(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to build Docker image: {stderr[:500]}",
)
logger.info("Successfully built image %s for instance %s", image_tag, instance_name)
logger.info(
"Successfully built image %s for instance %s",
image_tag,
instance_name,
)
# Generate compose for dockerfile-built image
# 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}"
""" 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"
services:
app:
image: {image_tag}
container_name: {instance_name.lower()}
stdin_open: true
tty: true
{ports_section} volumes:
- {repo_path}:/workspace
restart: unless-stopped
@@ -676,11 +749,14 @@ services:
"USER_ID": str(user_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
if data.clone_mode == "clone" and repo_path:
import yaml
compose_data = yaml.safe_load(compose_content)
repo_mounted = False
if compose_data and "services" in compose_data:
@@ -706,7 +782,9 @@ services:
svc["volumes"] = []
svc["volumes"].append(f"{repo_path}:/workspace")
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)
@@ -722,7 +800,9 @@ services:
compose_path=compose_path,
port=tool_port,
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,
)
session.add(instance)
@@ -737,7 +817,9 @@ services:
"status": instance.status,
"clone_mode": instance.clone_mode,
"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(),
}
except Exception as exc:
@@ -790,20 +872,22 @@ async def list_instances(
instances_data = []
for i in instances:
tool_type = await session.get(ToolType, i.tool_type_id)
instances_data.append({
"id": str(i.id),
"name": i.name,
"display_name": i.display_name,
"tool_type_id": str(i.tool_type_id),
"tool_type_name": tool_type.name if tool_type else "unknown",
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
"status": i.status,
"url": i.url,
"port": i.port,
"clone_mode": i.clone_mode,
"branch": i.branch,
"created_at": i.created_at.isoformat(),
})
instances_data.append(
{
"id": str(i.id),
"name": i.name,
"display_name": i.display_name,
"tool_type_id": str(i.tool_type_id),
"tool_type_name": tool_type.name if tool_type else "unknown",
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
"status": i.status,
"url": i.url,
"port": i.port,
"clone_mode": i.clone_mode,
"branch": i.branch,
"created_at": i.created_at.isoformat(),
}
)
return {"instances": instances_data}
@@ -864,9 +948,15 @@ async def get_instance(
"port": instance.port,
"clone_mode": instance.clone_mode,
"branch": instance.branch,
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
"selected_config_profile_id": str(instance.selected_config_profile_id)
if instance.selected_config_profile_id
else None,
"last_started_at": instance.last_started_at.isoformat()
if instance.last_started_at
else None,
"last_stopped_at": instance.last_stopped_at.isoformat()
if instance.last_stopped_at
else None,
"created_at": instance.created_at.isoformat(),
}
@@ -932,11 +1022,15 @@ async def start_instance(
extra_env_vars = {}
extra_volumes = []
config_query = select(ToolConfig).where(
ToolConfig.user_id == user_id,
ToolConfig.tool_type_id == instance.tool_type_id,
).where(
(ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None))
config_query = (
select(ToolConfig)
.where(
ToolConfig.user_id == user_id,
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)
@@ -968,9 +1062,11 @@ async def start_instance(
instance_dir = os.path.dirname(instance.compose_path)
if instance.selected_config_profile_id is not None:
try:
resolved = await resolve_profile(session, instance.selected_config_profile_id)
profile_env, profile_files, profile_mounts, profile_hints = apply_resolved_profile(
instance_dir, resolved
resolved = await resolve_profile(
session, instance.selected_config_profile_id
)
profile_env, profile_files, profile_mounts, profile_hints = (
apply_resolved_profile(instance_dir, resolved)
)
# Profile env vars override tool config env vars
env_vars.update(profile_env)
@@ -979,7 +1075,9 @@ async def start_instance(
# Profile mounts are added to extra volumes
extra_volumes.extend(profile_mounts)
# 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)
# Profile runtime hints override tool config values
if profile_hints.get("start_command"):
@@ -998,7 +1096,9 @@ async def start_instance(
len(git_mount_volumes),
)
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(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Config profile cycle detected: {exc}",
@@ -1015,7 +1115,9 @@ async def start_instance(
if 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
if instance.clone_mode == "clone":
@@ -1025,30 +1127,53 @@ async def start_instance(
if ssh_key:
try:
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
extra_volumes.append({
"source": ssh_dir,
"target": "/root/.ssh",
"type": "ro",
})
logger.debug("Mounted SSH key for clone-mode instance %s", instance.id)
extra_volumes.append(
{
"source": ssh_dir,
"target": "/root/.ssh",
"type": "ro",
}
)
logger.debug(
"Mounted SSH key for clone-mode instance %s", instance.id
)
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)
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)
# Sanitize compose file to remove invalid port mappings from old instances
_sanitize_compose_file(instance.compose_path)
# 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(
instance.compose_path, "up", env_file=env_file_path
)
logger.debug("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 "")
logger.debug(
"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:
instance.status = "error"
@@ -1085,7 +1210,9 @@ async def start_instance(
await session.commit()
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"]:
# Container failed to start
@@ -1141,7 +1268,10 @@ async def start_instance(
await session.commit()
logger.debug(
"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(
@@ -1192,15 +1322,24 @@ async def start_instance(
}
instance_port = tool_type.default_port or 0
logger.debug("Tool type for instance %s: name=%s, default_port=%s, interface_type=%s",
instance.id, tool_type.name, instance_port, tool_type.interface_type)
logger.debug(
"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
if tool_type.interface_type == "web":
# Create temporary Cloudflare tunnel for public access
try:
logger.debug("Creating temporary tunnel for instance %s (container=%s, port=%d)",
instance.id, instance.container_name, instance_port)
logger.debug(
"Creating temporary tunnel for instance %s (container=%s, port=%d)",
instance.id,
instance.container_name,
instance_port,
)
tunnel_info = start_cloudflared_tunnel(
container_name=instance.container_name or instance.name,
port=instance_port,
@@ -1217,6 +1356,7 @@ async def start_instance(
)
except Exception as exc:
import traceback
error_msg = str(exc)
error_trace = traceback.format_exc()
logger.error(
@@ -1234,7 +1374,10 @@ async def start_instance(
}
else:
# 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.public_url = None
await session.commit()
@@ -1279,9 +1422,15 @@ async def stop_instance(
if instance.tunnel_id:
try:
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:
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):
execute_compose_command(instance.compose_path, "stop")
@@ -1333,18 +1482,26 @@ async def restart_instance(
if instance.tunnel_id:
try:
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:
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
if instance.compose_path and os.path.exists(instance.compose_path):
instance_dir = os.path.dirname(instance.compose_path)
if instance.selected_config_profile_id is not None:
try:
resolved = await resolve_profile(session, instance.selected_config_profile_id)
profile_env, profile_files, profile_mounts, profile_hints = apply_resolved_profile(
instance_dir, resolved
resolved = await resolve_profile(
session, instance.selected_config_profile_id
)
profile_env, profile_files, profile_mounts, profile_hints = (
apply_resolved_profile(instance_dir, resolved)
)
# Write env file with resolved profile env vars
if profile_env:
@@ -1372,8 +1529,10 @@ async def restart_instance(
# Get tool type for default port
tool_type = await session.get(ToolType, instance.tool_type_id)
if not tool_type or not tool_type.default_port:
logger.error("Tool type %s has no default_port configured. Cannot create tunnel.",
instance.tool_type_id)
logger.error(
"Tool type %s has no default_port configured. Cannot create tunnel.",
instance.tool_type_id,
)
instance.status = "error"
await session.commit()
return {
@@ -1461,7 +1620,9 @@ async def delete_instance(
# Check dirty state for clone-mode instances
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:
clone_path = os.path.join(instance_dir, "repo-clone")
if os.path.exists(clone_path):
@@ -1480,9 +1641,15 @@ async def delete_instance(
if instance.tunnel_id:
try:
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:
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
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)
if os.path.exists(instance_dir):
import shutil
shutil.rmtree(instance_dir)
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.",
)
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
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:
tunnel_info = recreate_tunnel(
@@ -1674,7 +1848,9 @@ async def check_instance_tunnel_health(
if instance.status == "probing":
response["probe_status"] = "pending"
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", []))
# 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.get(
"/me/sessions",
summary="Get user sessions",
@@ -1860,7 +2035,11 @@ async def get_user_sessions(
result = await session.execute(
select(ToolInstance)
.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())
)
instances = result.scalars().all()
@@ -1871,22 +2050,28 @@ async def get_user_sessions(
repo = await session.get(GitRepository, instance.repository_id)
project = await session.get(Project, instance.project_id)
sessions.append({
"id": str(instance.id),
"display_name": instance.display_name,
"tool_type_name": tool_type.name if tool_type else "unknown",
"tool_icon": tool_type.name if tool_type else "code",
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
"repository_name": repo.name if repo else "unknown",
"repository_id": str(instance.repository_id),
"project_name": project.name if project else "unknown",
"project_id": str(instance.project_id),
"status": instance.status,
"url": instance.url,
"clone_mode": instance.clone_mode,
"branch": instance.branch,
"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,
})
sessions.append(
{
"id": str(instance.id),
"display_name": instance.display_name,
"tool_type_name": tool_type.name if tool_type else "unknown",
"tool_icon": tool_type.name if tool_type else "code",
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
"repository_name": repo.name if repo else "unknown",
"repository_id": str(instance.repository_id),
"project_name": project.name if project else "unknown",
"project_id": str(instance.project_id),
"status": instance.status,
"url": instance.url,
"clone_mode": instance.clone_mode,
"branch": instance.branch,
"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}
-2
View File
@@ -1,7 +1,6 @@
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
@@ -9,7 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.api.tool_types_validation import (
check_port_exposed,
sanitize_template_vars,
validate_compose_yaml,
validate_required_variables,
)
+3 -4
View File
@@ -1,17 +1,16 @@
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends
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.models.user import User
from src.models.user_config import UserConfig
logger = logging.getLogger(__name__)
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.config import Settings
from src.database import SessionLocal
from src.models.project import Project
from src.models.user import User
-2
View File
@@ -1,4 +1,3 @@
import json
import logging
import os
@@ -7,7 +6,6 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from src.api.auth import router as auth_router
from src.api.dashboard import router as dashboard_router
+8 -10
View File
@@ -1,7 +1,9 @@
"""Docker service for managing tool instances."""
import os
import re
import subprocess
import time
from pathlib import Path
from typing import Any
@@ -137,6 +139,8 @@ def execute_compose_command(
def get_container_id(instance_name: str) -> str | None:
"""Get the container ID for a compose service.
Searches all containers including stopped/exited ones.
Args:
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
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,
text=True,
)
@@ -158,6 +162,8 @@ def get_container_id(instance_name: str) -> str | None:
def get_container_name(instance_name: str) -> str | None:
"""Get the full container name for a compose service.
Searches all containers including stopped/exited ones.
Args:
instance_name: The service name in compose
@@ -169,6 +175,7 @@ def get_container_name(instance_name: str) -> str | None:
[
"docker",
"ps",
"-a",
"--format",
"{{.Names}}",
"--filter",
@@ -252,7 +259,6 @@ def wait_for_container_running(
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
and 'waited_seconds' (float)
"""
import 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}")
import subprocess
import time
import re
def start_cloudflared_tunnel(
container_name: str, port: int, timeout: int = 30
) -> dict[str, str]:
@@ -358,8 +359,6 @@ def start_cloudflared_tunnel(
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
"""
import subprocess
import time
import re
import logging
logger = logging.getLogger(__name__)
@@ -441,7 +440,6 @@ def stop_cloudflared_tunnel(pid: str) -> None:
Args:
pid: Process ID of the cloudflared tunnel
"""
import os
import signal
try:
-1
View File
@@ -18,7 +18,6 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
Returns:
Tuple of (returncode, stdout, stderr)
"""
import os
from pathlib import Path
# Write Dockerfile
@@ -3,7 +3,6 @@
import asyncio
import logging
import uuid
from typing import Any
from fastapi import WebSocket
-1
View File
@@ -2,7 +2,6 @@
import subprocess
from dataclasses import dataclass, field
from typing import Any
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_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, build_database_url
from src.config import Settings
from src.models.base import Base
from src.main import app
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
@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
@@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta
import asyncio
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
@@ -1,4 +1,3 @@
import uuid
import pytest
from fastapi.testclient import TestClient
@@ -1,4 +1,3 @@
import uuid
import pytest
from fastapi.testclient import TestClient
@@ -221,7 +220,7 @@ class TestToolTypesAPIExtended:
},
)
assert response.status_code == 422
data = response.json()
_ = response.json()
def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None:
"""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 (
ConfigProfileCycleError,
ConfigProfileNotFoundError,
ResolvedProfile,
check_include_cycle,
resolve_profile,
_merge_env_vars,
@@ -63,7 +62,6 @@ class TestMergeFunctions:
def test_merge_mounts_basic(self) -> None:
"""Test basic mount merging."""
from src.services.config_profile_resolver import ResolvedMount
result = _merge_mounts(
{},
[{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}],
@@ -1,7 +1,6 @@
"""Unit tests for git mount resolution in tool instances."""
import os
import tempfile
from pathlib import Path
import pytest
@@ -11,7 +10,6 @@ from src.api.tool_instances import (
_expand_glob_source,
_resolve_single_git_mount,
)
from src.services.config_profile_resolver import ResolvedProfile
class TestExpandGlobSource:
@@ -1,6 +1,5 @@
"""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
@@ -1,9 +1,7 @@
"""Unit tests for readiness probe service."""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from src.services.readiness_probe import execute_probe
@@ -3,10 +3,7 @@
import os
import subprocess
import tempfile
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
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";
@@ -19,7 +19,7 @@ const MAX_RETRIES = 2;
const RETRY_DELAY_MS = 1000;
// Track retry count per request
const retryCount = new WeakMap<any, number>();
const retryCount = new WeakMap<AxiosRequestConfig, number>();
apiClient.interceptors.response.use(
(response) => response,
+7 -4
View File
@@ -1,3 +1,4 @@
import { AxiosError } from "axios";
import { apiClient } from "./client";
export interface ToolInstance {
@@ -80,9 +81,10 @@ export async function startInstance(
{ config_profile_id: configProfileId }
);
return response.data;
} catch (error: any) {
} catch (error) {
// 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));
return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
}
@@ -114,9 +116,10 @@ export async function restartInstance(
{ config_profile_id: configProfileId }
);
return response.data;
} catch (error: any) {
} catch (error) {
// 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));
return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
}
@@ -1,5 +1,4 @@
import { useState } from "react";
import { Icon } from "./icon";
interface FormField {
name: string;
@@ -22,8 +22,6 @@ interface MobileListViewProps {
export const MobileListView: React.FC<MobileListViewProps> = ({
items,
onItemClick,
onItemDelete,
onItemDuplicate,
emptyMessage = "No items found",
searchPlaceholder = "Search...",
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 { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
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 type { Project } from "../types";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list";
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 { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry, type CommitHistoryResponse } from "../api/git_repositories";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryResponse } from "../api/git_repositories";
import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data";
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
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 { useAuth } from "../state/auth";
import { useAsyncData } from "../hooks/use-async-data";
+1 -3
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { listProjects } from "../api/projects";
import type { Project } from "../types";
@@ -11,7 +10,7 @@ import {
} from "../api/sessions";
import { listToolTypes, type ToolType } from "../api/tool_types";
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 { SessionList } from "../components/session-list";
import { SessionCard } from "../components/session-card";
@@ -21,7 +20,6 @@ import type { InstanceHealth } from "../api/sessions";
type SessionsStatus = "loading" | "ready" | "error";
export const SessionsPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<SessionsStatus>("loading");
const [sessions, setSessions] = useState<Session[]>([]);
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 { 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 { useAsyncData } from "../hooks/use-async-data";
+1 -1
View File
@@ -7,7 +7,7 @@ import { useAsyncData } from "../hooks/use-async-data";
export const SSHKeysPage = () => {
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 [generating, setGenerating] = useState(false);
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});