Merge branch 'fix/container-name-case-sensitivity' into dev
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,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,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);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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>>({});
|
||||||
|
|||||||
Reference in New Issue
Block a user