Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22474cdba5 | |||
| 0c839e8c6f | |||
| c63cf7db50 | |||
| d9d2b91384 | |||
| d6ea5fb1fd | |||
| 1883825b18 | |||
| bc71fd6fac | |||
| 28aa9ccf5a | |||
| 44dd80cb58 | |||
| 23485833d8 | |||
| e23dcdf4e1 | |||
| f05ac55875 | |||
| bcefeb4163 | |||
| 33d08faf70 |
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""add pi agent tool type
|
||||||
|
|
||||||
|
Revision ID: 20260527_160017_add_pi_agent
|
||||||
|
Revises: f3d2dc90ba3a
|
||||||
|
Create Date: 2026-05-27T16:00:17
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "20260527_160017_add_pi_agent"
|
||||||
|
down_revision: Union[str, None] = "2026_05_27_external_repos"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
PI_AGENT_ID = uuid.UUID("d07b8376-2151-4119-8c1d-27f792aae9a3")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Check if pi-agent already exists
|
||||||
|
conn = op.get_bind()
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
INSERT INTO tool_types (
|
||||||
|
id, name, display_name, description, category,
|
||||||
|
interface_type, requires_port, default_port,
|
||||||
|
definition_type, compose_template, dockerfile_template, required_variables,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
:id, :name, :display_name, :description, :category,
|
||||||
|
:interface_type, :requires_port, :default_port,
|
||||||
|
:definition_type, :compose_template, :dockerfile_template, :required_variables,
|
||||||
|
now(), now()
|
||||||
|
)
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"id": PI_AGENT_ID,
|
||||||
|
"name": "pi-agent",
|
||||||
|
"display_name": "Pi Agent",
|
||||||
|
"description": "Pi coding agent terminal environment with nvim, ranger, and tmux",
|
||||||
|
"category": "development",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"requires_port": False,
|
||||||
|
"default_port": 0,
|
||||||
|
"definition_type": "dockerfile",
|
||||||
|
"compose_template": """services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
volumes:
|
||||||
|
- ${REPO_PATH}:/workspace
|
||||||
|
working_dir: /workspace
|
||||||
|
command: /bin/bash""",
|
||||||
|
"dockerfile_template": """# Pi Coding Agent - Terminal-based coding harness
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install base dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \\
|
||||||
|
curl \\
|
||||||
|
wget \\
|
||||||
|
git \\
|
||||||
|
neovim \\
|
||||||
|
ranger \\
|
||||||
|
tmux \\
|
||||||
|
htop \\
|
||||||
|
tree \\
|
||||||
|
jq \\
|
||||||
|
ca-certificates \\
|
||||||
|
python3 \\
|
||||||
|
python3-pip \\
|
||||||
|
build-essential \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js (required for Pi)
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \\
|
||||||
|
&& apt-get install -y nodejs \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Pi Coding Agent globally
|
||||||
|
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -s /bin/bash user
|
||||||
|
WORKDIR /home/user
|
||||||
|
|
||||||
|
# Set up git
|
||||||
|
RUN git config --global init.defaultBranch main \\
|
||||||
|
&& git config --global user.email "dev@headquarter.local" \\
|
||||||
|
&& git config --global user.name "Developer"
|
||||||
|
|
||||||
|
# Create default tmux config
|
||||||
|
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||||
|
|
||||||
|
# Create default ranger config
|
||||||
|
RUN mkdir -p /home/user/.config/ranger \\
|
||||||
|
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
|
||||||
|
|
||||||
|
# Set up Pi config directory
|
||||||
|
RUN mkdir -p /home/user/.pi/agent
|
||||||
|
|
||||||
|
USER user
|
||||||
|
|
||||||
|
# Default to bash (Pi is invoked manually via `pi` command)
|
||||||
|
CMD ["/bin/bash"]""",
|
||||||
|
"required_variables": json.dumps(["REPO_PATH"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
conn.execute(
|
||||||
|
sa.text("DELETE FROM tool_types WHERE name = 'pi-agent'")
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
|||||||
+10
-10
@@ -48,7 +48,7 @@ async def login(next: str = "/") -> RedirectResponse:
|
|||||||
redirect_uri=redirect_uri,
|
redirect_uri=redirect_uri,
|
||||||
state=state,
|
state=state,
|
||||||
)
|
)
|
||||||
logger.info("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
|
logger.debug("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
|
||||||
response = RedirectResponse(location)
|
response = RedirectResponse(location)
|
||||||
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
|
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
|
||||||
response.set_cookie("auth_next", next, httponly=True, samesite="lax")
|
response.set_cookie("auth_next", next, httponly=True, samesite="lax")
|
||||||
@@ -63,7 +63,7 @@ async def callback(
|
|||||||
auth_next: str | None = Cookie(default="/"),
|
auth_next: str | None = Cookie(default="/"),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
logger.info("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
|
logger.debug("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
|
||||||
|
|
||||||
if auth_state is None or auth_state != state:
|
if auth_state is None or auth_state != state:
|
||||||
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
|
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
|
||||||
@@ -71,7 +71,7 @@ async def callback(
|
|||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
||||||
logger.info("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
|
logger.debug("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
@@ -92,7 +92,7 @@ async def callback(
|
|||||||
access_token=token_payload["access_token"],
|
access_token=token_payload["access_token"],
|
||||||
client=client,
|
client=client,
|
||||||
)
|
)
|
||||||
logger.info("User info fetched successfully")
|
logger.debug("User info fetched successfully")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("User info fetch failed: %s", exc)
|
logger.error("User info fetch failed: %s", exc)
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info")
|
||||||
@@ -100,19 +100,19 @@ async def callback(
|
|||||||
authentik_id = str(user_info.get("sub", ""))
|
authentik_id = str(user_info.get("sub", ""))
|
||||||
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
|
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
|
||||||
name = str(user_info.get("name", email))
|
name = str(user_info.get("name", email))
|
||||||
logger.info("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
|
logger.debug("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
|
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
|
||||||
if user is None:
|
if user is None:
|
||||||
logger.info("Creating new user: authentik_id=%s", authentik_id)
|
logger.debug("Creating new user: authentik_id=%s", authentik_id)
|
||||||
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
|
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
logger.info("New user created: id=%s", user.id)
|
logger.info("New user created: id=%s", user.id)
|
||||||
else:
|
else:
|
||||||
logger.info("Existing user found: id=%s, updating info", user.id)
|
logger.debug("Existing user found: id=%s, updating info", user.id)
|
||||||
user.email = email
|
user.email = email
|
||||||
user.name = name
|
user.name = name
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -165,20 +165,20 @@ async def me(
|
|||||||
session_cookie: str | None = Cookie(default=None, alias="session"),
|
session_cookie: str | None = Cookie(default=None, alias="session"),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
logger.info("Auth /me called, cookie present: %s", bool(session_cookie))
|
logger.debug("Auth /me called, cookie present: %s", bool(session_cookie))
|
||||||
|
|
||||||
if not session_cookie:
|
if not session_cookie:
|
||||||
logger.warning("Auth /me: missing session cookie")
|
logger.warning("Auth /me: missing session cookie")
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
logger.info("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
|
logger.debug("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
|
||||||
settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
|
settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||||
user_id = payload["user_id"]
|
user_id = payload["user_id"]
|
||||||
logger.info("Auth /me: decoded session for user_id=%s", user_id)
|
logger.debug("Auth /me: decoded session for user_id=%s", user_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
logger.warning("Auth /me: invalid session: %s", exc)
|
logger.warning("Auth /me: invalid session: %s", exc)
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -82,8 +81,6 @@ class GitMountItem(BaseModel):
|
|||||||
@field_validator("target_path")
|
@field_validator("target_path")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_target_path(cls, v: str) -> str:
|
def validate_target_path(cls, v: str) -> str:
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("target_path must be absolute (start with /)")
|
|
||||||
if ".." in v:
|
if ".." in v:
|
||||||
raise ValueError("target_path cannot contain path traversal (..)")
|
raise ValueError("target_path cannot contain path traversal (..)")
|
||||||
return v
|
return v
|
||||||
@@ -422,7 +419,7 @@ async def create_config_profile(
|
|||||||
)
|
)
|
||||||
profile = result.scalar_one()
|
profile = result.scalar_one()
|
||||||
|
|
||||||
logger.info("Created config profile %s for user %s", profile.id, user_uuid)
|
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
|
||||||
return _profile_to_response(profile)
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@@ -523,7 +520,7 @@ async def update_config_profile(
|
|||||||
)
|
)
|
||||||
profile = result.scalar_one()
|
profile = result.scalar_one()
|
||||||
|
|
||||||
logger.info("Updated config profile %s", profile.id)
|
logger.debug("Updated config profile %s", profile.id)
|
||||||
return _profile_to_response(profile)
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@@ -543,7 +540,7 @@ async def delete_config_profile(
|
|||||||
await session.delete(profile)
|
await session.delete(profile)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
logger.info("Deleted config profile %s", profile_id)
|
logger.debug("Deleted config profile %s", profile_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -628,7 +625,7 @@ async def update_profile_includes(
|
|||||||
)
|
)
|
||||||
direct_includes = inc_result.scalars().all()
|
direct_includes = inc_result.scalars().all()
|
||||||
|
|
||||||
logger.info("Updated includes for config profile %s", profile.id)
|
logger.debug("Updated includes for config profile %s", profile.id)
|
||||||
return _profile_to_response(profile, list(direct_includes))
|
return _profile_to_response(profile, list(direct_includes))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -44,9 +44,9 @@ async def terminal_websocket(
|
|||||||
Returns:
|
Returns:
|
||||||
None. Communicates via WebSocket messages.
|
None. Communicates via WebSocket messages.
|
||||||
"""
|
"""
|
||||||
logger.info("Terminal WebSocket connection attempt for instance %s", instance_id)
|
logger.debug("Terminal WebSocket connection attempt for instance %s", instance_id)
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
logger.info("Terminal WebSocket accepted for instance %s", instance_id)
|
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Parse instance_id
|
# Parse instance_id
|
||||||
@@ -80,13 +80,13 @@ async def terminal_websocket(
|
|||||||
await websocket.close(code=4004, reason="Instance not running")
|
await websocket.close(code=4004, reason="Instance not running")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Terminal auth passed for instance %s, user %s", instance_id, user_id)
|
logger.debug("Terminal auth passed for instance %s, user %s", instance_id, user_id)
|
||||||
|
|
||||||
# Fetch tool type to get startup_command
|
# Fetch tool type to get startup_command
|
||||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
startup_command = tool_type.startup_command if tool_type else None
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
if startup_command:
|
if startup_command:
|
||||||
logger.info("Using startup command for instance %s: %s", instance_id, startup_command)
|
logger.debug("Using startup command for instance %s: %s", instance_id, startup_command)
|
||||||
|
|
||||||
# Get or create terminal session
|
# Get or create terminal session
|
||||||
try:
|
try:
|
||||||
@@ -95,15 +95,15 @@ async def terminal_websocket(
|
|||||||
instance.container_id,
|
instance.container_id,
|
||||||
startup_command=startup_command,
|
startup_command=startup_command,
|
||||||
)
|
)
|
||||||
logger.info("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
|
logger.debug("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
|
||||||
|
|
||||||
# Attach WebSocket to session
|
# Attach WebSocket to session
|
||||||
await terminal_manager.attach_websocket(session, websocket)
|
await terminal_manager.attach_websocket(session, websocket)
|
||||||
logger.info("WebSocket attached to session for instance %s", instance_id)
|
logger.debug("WebSocket attached to session for instance %s", instance_id)
|
||||||
|
|
||||||
# Send connected status
|
# Send connected status
|
||||||
await websocket.send_json({"type": "status", "status": "connected"})
|
await websocket.send_json({"type": "status", "status": "connected"})
|
||||||
logger.info("Sent connected status for instance %s", instance_id)
|
logger.debug("Sent connected status for instance %s", instance_id)
|
||||||
|
|
||||||
# Use mutable session reference so loops can survive reset
|
# Use mutable session reference so loops can survive reset
|
||||||
session_ref = SessionRef(session)
|
session_ref = SessionRef(session)
|
||||||
@@ -112,7 +112,7 @@ async def terminal_websocket(
|
|||||||
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
||||||
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
|
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
|
||||||
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
||||||
logger.info("Started terminal loops for instance %s", instance_id)
|
logger.debug("Started terminal loops for instance %s", instance_id)
|
||||||
|
|
||||||
# Wait for either task to complete (indicating disconnect or error)
|
# Wait for either task to complete (indicating disconnect or error)
|
||||||
done, pending = await asyncio.wait(
|
done, pending = await asyncio.wait(
|
||||||
@@ -120,7 +120,7 @@ async def terminal_websocket(
|
|||||||
return_when=asyncio.FIRST_COMPLETED,
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Terminal loop completed for instance %s, done=%s", instance_id, len(done))
|
logger.debug("Terminal loop completed for instance %s, done=%s", instance_id, len(done))
|
||||||
|
|
||||||
# Cancel remaining tasks
|
# Cancel remaining tasks
|
||||||
for task in pending:
|
for task in pending:
|
||||||
@@ -134,7 +134,7 @@ async def terminal_websocket(
|
|||||||
try:
|
try:
|
||||||
if 'session' in locals():
|
if 'session' in locals():
|
||||||
await terminal_manager.detach_websocket(session, websocket)
|
await terminal_manager.detach_websocket(session, websocket)
|
||||||
logger.info("WebSocket detached from session for instance %s", instance_id)
|
logger.debug("WebSocket detached from session for instance %s", instance_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -183,11 +183,11 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
|||||||
if msg_type == "resize":
|
if msg_type == "resize":
|
||||||
cols = ctrl.get("cols", 80)
|
cols = ctrl.get("cols", 80)
|
||||||
rows = ctrl.get("rows", 24)
|
rows = ctrl.get("rows", 24)
|
||||||
logger.info(f"Received resize message for instance {instance_id}: {cols}x{rows}")
|
logger.debug(f"Received resize message for instance {instance_id}: {cols}x{rows}")
|
||||||
await session.resize(cols, rows)
|
await session.resize(cols, rows)
|
||||||
elif msg_type == "reset":
|
elif msg_type == "reset":
|
||||||
# Reset terminal session
|
# Reset terminal session
|
||||||
logger.info("Resetting terminal session for instance %s", session.instance_id)
|
logger.debug("Resetting terminal session for instance %s", session.instance_id)
|
||||||
await websocket.send_json({"type": "status", "status": "resetting"})
|
await websocket.send_json({"type": "status", "status": "resetting"})
|
||||||
|
|
||||||
# Reset the session
|
# Reset the 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"])
|
||||||
|
|
||||||
|
|||||||
+378
-129
@@ -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,24 +61,18 @@ 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,
|
||||||
resolved: ResolvedProfile,
|
resolved: ResolvedProfile,
|
||||||
instance_dir: str | None = None,
|
instance_dir: str | None = None,
|
||||||
|
working_directory: str | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Convert git mounts from resolved profile to Docker volume mounts.
|
"""Convert git mounts from resolved profile to Docker volume mounts.
|
||||||
|
|
||||||
@@ -74,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))
|
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)
|
||||||
|
|
||||||
@@ -93,6 +109,7 @@ async def _resolve_single_git_mount(
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
git_mount: dict,
|
git_mount: dict,
|
||||||
instance_dir: str | None = None,
|
instance_dir: str | None = None,
|
||||||
|
working_directory: str | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Resolve a single git mount to volume mount entries.
|
"""Resolve a single git mount to volume mount entries.
|
||||||
|
|
||||||
@@ -108,28 +125,43 @@ async def _resolve_single_git_mount(
|
|||||||
logger.warning("Invalid git mount skipped: missing remote_url or target_path")
|
logger.warning("Invalid git mount skipped: missing remote_url or target_path")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# Resolve relative target paths against working directory
|
||||||
|
if target_path and not target_path.startswith("/"):
|
||||||
|
if not working_directory:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
target_path = os.path.join(working_directory, target_path)
|
||||||
|
logger.debug("Resolved relative target path to %s", target_path)
|
||||||
|
|
||||||
if not instance_dir:
|
if not instance_dir:
|
||||||
logger.warning("Git mount skipped: no instance_dir provided for cloning")
|
logger.warning("Git mount skipped: no instance_dir provided for cloning")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# 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_dir = 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}")
|
||||||
|
# clone_repository always creates 'repo-clone' inside the given directory
|
||||||
|
repo_path = os.path.join(clone_parent, "repo-clone")
|
||||||
|
|
||||||
# Clone or pull the repository
|
# Clone or pull the repository
|
||||||
repo_path = clone_dir
|
if not os.path.exists(repo_path):
|
||||||
if not os.path.exists(clone_dir):
|
|
||||||
try:
|
try:
|
||||||
|
os.makedirs(clone_parent, exist_ok=True)
|
||||||
repo_path = await asyncio.to_thread(
|
repo_path = await asyncio.to_thread(
|
||||||
clone_repository,
|
clone_repository,
|
||||||
remote_url,
|
remote_url,
|
||||||
None, # No SSH key for now - can be added later
|
None, # No SSH key for now - can be added later
|
||||||
os.path.dirname(clone_dir),
|
clone_parent,
|
||||||
branch or "main",
|
branch or "main",
|
||||||
)
|
)
|
||||||
logger.info("Cloned git mount repository %s to %s", remote_url, repo_path)
|
logger.debug("Cloned git mount repository %s to %s", remote_url, repo_path)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Clone failed for git mount %s: %s", remote_url, exc)
|
logger.warning("Clone failed for git mount %s: %s", remote_url, exc)
|
||||||
return []
|
return []
|
||||||
@@ -137,7 +169,7 @@ async def _resolve_single_git_mount(
|
|||||||
# Repo exists - pull latest updates
|
# Repo exists - pull latest updates
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(_pull_repository_updates, repo_path, remote_url)
|
await asyncio.to_thread(_pull_repository_updates, repo_path, remote_url)
|
||||||
logger.info("Pulled updates for git mount %s", remote_url)
|
logger.debug("Pulled updates for git mount %s", remote_url)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to pull updates for %s: %s", remote_url, exc)
|
logger.warning("Failed to pull updates for %s: %s", remote_url, exc)
|
||||||
|
|
||||||
@@ -145,11 +177,10 @@ async def _resolve_single_git_mount(
|
|||||||
if branch and repo_path:
|
if branch and repo_path:
|
||||||
success = await asyncio.to_thread(_checkout_branch, repo_path, branch)
|
success = await asyncio.to_thread(_checkout_branch, repo_path, branch)
|
||||||
if success:
|
if success:
|
||||||
logger.info("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
|
||||||
@@ -162,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 = []
|
||||||
@@ -179,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,
|
"source": matched_path,
|
||||||
"target": final_target,
|
"target": final_target,
|
||||||
"type": "bind",
|
"type": "bind",
|
||||||
})
|
}
|
||||||
logger.info("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
|
||||||
|
|
||||||
@@ -218,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
|
||||||
@@ -277,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
|
||||||
@@ -292,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):
|
||||||
@@ -304,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(
|
||||||
@@ -370,6 +433,47 @@ async def _validate_config_profile(
|
|||||||
return profile_uuid
|
return profile_uuid
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_compose_file(compose_path: str) -> None:
|
||||||
|
"""Remove invalid port mappings (target port 0) from compose file."""
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
compose_file = Path(compose_path)
|
||||||
|
if not compose_file.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
content = compose_file.read_text()
|
||||||
|
compose_data = yaml.safe_load(content)
|
||||||
|
|
||||||
|
if not compose_data or "services" not in compose_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
modified = False
|
||||||
|
for service_name, service_config in compose_data["services"].items():
|
||||||
|
if "ports" in service_config:
|
||||||
|
valid_ports = []
|
||||||
|
for port_mapping in service_config["ports"]:
|
||||||
|
if isinstance(port_mapping, str) and ":" in port_mapping:
|
||||||
|
parts = port_mapping.split(":")
|
||||||
|
if len(parts) == 2:
|
||||||
|
host_port, container_port = parts
|
||||||
|
# Skip invalid mappings (target port 0 or empty)
|
||||||
|
if container_port == "0" or not container_port:
|
||||||
|
modified = True
|
||||||
|
continue
|
||||||
|
valid_ports.append(port_mapping)
|
||||||
|
|
||||||
|
if valid_ports:
|
||||||
|
service_config["ports"] = valid_ports
|
||||||
|
else:
|
||||||
|
del service_config["ports"]
|
||||||
|
modified = True
|
||||||
|
break # Only check first service
|
||||||
|
|
||||||
|
if modified:
|
||||||
|
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
||||||
|
|
||||||
|
|
||||||
def _modify_compose_file(
|
def _modify_compose_file(
|
||||||
compose_path: str,
|
compose_path: str,
|
||||||
port_override: int | None = None,
|
port_override: int | None = None,
|
||||||
@@ -447,7 +551,7 @@ async def create_instance(
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary with instance details.
|
Dictionary with instance details.
|
||||||
"""
|
"""
|
||||||
logger.info(
|
logger.debug(
|
||||||
"Creating instance: project_id=%s, repo_id=%s, tool_type_id=%s, display_name=%s",
|
"Creating instance: project_id=%s, repo_id=%s, tool_type_id=%s, display_name=%s",
|
||||||
project_id,
|
project_id,
|
||||||
repo_id,
|
repo_id,
|
||||||
@@ -481,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)
|
||||||
@@ -507,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
|
||||||
@@ -529,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
|
||||||
@@ -538,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.info("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
|
||||||
@@ -558,23 +670,28 @@ 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.info("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
|
||||||
if tool_type.definition_type == "dockerfile":
|
if tool_type.definition_type == "dockerfile":
|
||||||
# Build image from Dockerfile
|
# Build image from Dockerfile
|
||||||
image_tag = f"headquarter/{instance_name}:latest"
|
image_tag = f"headquarter/{instance_name}:latest".lower()
|
||||||
|
|
||||||
if tool_type.dockerfile_template:
|
if tool_type.dockerfile_template:
|
||||||
returncode, stdout, stderr = build_image(
|
returncode, stdout, stderr = await asyncio.to_thread(
|
||||||
|
build_image,
|
||||||
instance_dir=instance_dir,
|
instance_dir=instance_dir,
|
||||||
dockerfile=tool_type.dockerfile_template,
|
dockerfile=tool_type.dockerfile_template,
|
||||||
tag=image_tag,
|
tag=image_tag,
|
||||||
@@ -582,23 +699,40 @@ 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)
|
||||||
|
ports_section = (
|
||||||
|
f""" ports:
|
||||||
|
- "{tool_port}:{tool_type.default_port}"
|
||||||
|
"""
|
||||||
|
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}
|
container_name: {instance_name.lower()}
|
||||||
ports:
|
stdin_open: true
|
||||||
- "{tool_port}:{tool_type.default_port}"
|
tty: true
|
||||||
volumes:
|
{ports_section} volumes:
|
||||||
- {repo_path}:/workspace
|
- {repo_path}:/workspace
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
"""
|
"""
|
||||||
@@ -615,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:
|
||||||
@@ -645,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)
|
||||||
|
|
||||||
@@ -661,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)
|
||||||
@@ -676,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:
|
||||||
@@ -729,7 +872,8 @@ 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),
|
"id": str(i.id),
|
||||||
"name": i.name,
|
"name": i.name,
|
||||||
"display_name": i.display_name,
|
"display_name": i.display_name,
|
||||||
@@ -742,7 +886,8 @@ async def list_instances(
|
|||||||
"clone_mode": i.clone_mode,
|
"clone_mode": i.clone_mode,
|
||||||
"branch": i.branch,
|
"branch": i.branch,
|
||||||
"created_at": i.created_at.isoformat(),
|
"created_at": i.created_at.isoformat(),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {"instances": instances_data}
|
return {"instances": instances_data}
|
||||||
|
|
||||||
@@ -803,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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -871,16 +1022,20 @@ async def start_instance(
|
|||||||
extra_env_vars = {}
|
extra_env_vars = {}
|
||||||
extra_volumes = []
|
extra_volumes = []
|
||||||
|
|
||||||
config_query = select(ToolConfig).where(
|
config_query = (
|
||||||
|
select(ToolConfig)
|
||||||
|
.where(
|
||||||
ToolConfig.user_id == user_id,
|
ToolConfig.user_id == user_id,
|
||||||
ToolConfig.tool_type_id == instance.tool_type_id,
|
ToolConfig.tool_type_id == instance.tool_type_id,
|
||||||
).where(
|
)
|
||||||
|
.where(
|
||||||
(ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None))
|
(ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None))
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
config_result = await session.execute(config_query)
|
config_result = await session.execute(config_query)
|
||||||
configs = config_result.scalars().all()
|
configs = config_result.scalars().all()
|
||||||
logger.info("Found %d tool configs for instance %s", len(configs), instance.id)
|
logger.debug("Found %d tool configs for instance %s", len(configs), instance.id)
|
||||||
|
|
||||||
for config in configs:
|
for config in configs:
|
||||||
if config.config_type == "env":
|
if config.config_type == "env":
|
||||||
@@ -907,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)
|
||||||
@@ -918,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)
|
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"):
|
||||||
@@ -927,7 +1086,7 @@ async def start_instance(
|
|||||||
working_directory = profile_hints["working_directory"]
|
working_directory = profile_hints["working_directory"]
|
||||||
if profile_hints.get("port_override"):
|
if profile_hints.get("port_override"):
|
||||||
port_override = profile_hints["port_override"]
|
port_override = profile_hints["port_override"]
|
||||||
logger.info(
|
logger.debug(
|
||||||
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)",
|
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)",
|
||||||
resolved.profile_name,
|
resolved.profile_name,
|
||||||
instance.id,
|
instance.id,
|
||||||
@@ -937,24 +1096,28 @@ 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}",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("No config profile selected for instance %s", instance.id)
|
logger.debug("No config profile selected for instance %s", instance.id)
|
||||||
|
|
||||||
# Write env file and config files
|
# Write env file and config files
|
||||||
env_file_path = None
|
env_file_path = None
|
||||||
|
|
||||||
if env_vars:
|
if env_vars:
|
||||||
env_file_path = write_env_file(instance_dir, env_vars)
|
env_file_path = write_env_file(instance_dir, env_vars)
|
||||||
logger.info("Wrote env file for instance %s: %s", instance.id, env_file_path)
|
logger.debug("Wrote env file for instance %s: %s", instance.id, env_file_path)
|
||||||
|
|
||||||
if config_files:
|
if config_files:
|
||||||
write_config_files(instance_dir, config_files)
|
write_config_files(instance_dir, config_files)
|
||||||
logger.info("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":
|
||||||
@@ -964,27 +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,
|
"source": ssh_dir,
|
||||||
"target": "/root/.ssh",
|
"target": "/root/.ssh",
|
||||||
"type": "ro",
|
"type": "ro",
|
||||||
})
|
}
|
||||||
logger.info("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(
|
||||||
logger.info("Modified compose file for instance %s", instance.id)
|
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
|
# Execute docker compose up with env file
|
||||||
logger.info("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.info("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"
|
||||||
@@ -999,18 +1188,18 @@ async def start_instance(
|
|||||||
container_id = get_container_id(instance.name)
|
container_id = get_container_id(instance.name)
|
||||||
if container_id:
|
if container_id:
|
||||||
instance.container_id = container_id
|
instance.container_id = container_id
|
||||||
logger.info("Container ID for instance %s: %s", instance.id, container_id)
|
logger.debug("Container ID for instance %s: %s", instance.id, container_id)
|
||||||
|
|
||||||
container_name = get_container_name(instance.name)
|
container_name = get_container_name(instance.name)
|
||||||
if container_name:
|
if container_name:
|
||||||
instance.container_name = container_name
|
instance.container_name = container_name
|
||||||
logger.info("Container name for instance %s: %s", instance.id, container_name)
|
logger.debug("Container name for instance %s: %s", instance.id, container_name)
|
||||||
|
|
||||||
# Connect container to backend network so API can reach it
|
# Connect container to backend network so API can reach it
|
||||||
logger.info("Connecting container %s to backend network...", container_name)
|
logger.debug("Connecting container %s to backend network...", container_name)
|
||||||
connected = connect_container_to_network(container_name, "backend")
|
connected = connect_container_to_network(container_name, "backend")
|
||||||
if connected:
|
if connected:
|
||||||
logger.info("Successfully connected %s to backend network", container_name)
|
logger.debug("Successfully connected %s to backend network", container_name)
|
||||||
else:
|
else:
|
||||||
logger.warning("Failed to connect %s to backend network", container_name)
|
logger.warning("Failed to connect %s to backend network", container_name)
|
||||||
|
|
||||||
@@ -1019,9 +1208,11 @@ async def start_instance(
|
|||||||
instance.status = "starting"
|
instance.status = "starting"
|
||||||
instance.last_started_at = datetime.now()
|
instance.last_started_at = datetime.now()
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info("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
|
||||||
@@ -1047,7 +1238,7 @@ async def start_instance(
|
|||||||
"logs": logs,
|
"logs": logs,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.debug(
|
||||||
"Instance %s container started successfully after %.1fs",
|
"Instance %s container started successfully after %.1fs",
|
||||||
instance.id,
|
instance.id,
|
||||||
startup_result["waited_seconds"],
|
startup_result["waited_seconds"],
|
||||||
@@ -1075,9 +1266,12 @@ async def start_instance(
|
|||||||
if probe_command:
|
if probe_command:
|
||||||
instance.status = "probing"
|
instance.status = "probing"
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(
|
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(
|
||||||
@@ -1128,15 +1322,24 @@ async def start_instance(
|
|||||||
}
|
}
|
||||||
|
|
||||||
instance_port = tool_type.default_port or 0
|
instance_port = tool_type.default_port or 0
|
||||||
logger.info("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.info("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,
|
||||||
@@ -1145,7 +1348,7 @@ async def start_instance(
|
|||||||
instance.public_url = tunnel_info["url"]
|
instance.public_url = tunnel_info["url"]
|
||||||
instance.url = tunnel_info["url"]
|
instance.url = tunnel_info["url"]
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(
|
logger.debug(
|
||||||
"Created temporary tunnel for instance %s: pid=%s, url=%s",
|
"Created temporary tunnel for instance %s: pid=%s, url=%s",
|
||||||
instance.id,
|
instance.id,
|
||||||
tunnel_info["pid"],
|
tunnel_info["pid"],
|
||||||
@@ -1153,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(
|
||||||
@@ -1170,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()
|
||||||
@@ -1215,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.info("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")
|
||||||
@@ -1269,23 +1482,31 @@ 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.info("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:
|
||||||
write_env_file(instance_dir, profile_env)
|
write_env_file(instance_dir, profile_env)
|
||||||
logger.info(
|
logger.debug(
|
||||||
"Re-applied config profile %s on restart for instance %s",
|
"Re-applied config profile %s on restart for instance %s",
|
||||||
resolved.profile_name,
|
resolved.profile_name,
|
||||||
instance.id,
|
instance.id,
|
||||||
@@ -1308,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 {
|
||||||
@@ -1330,7 +1553,7 @@ async def restart_instance(
|
|||||||
instance.tunnel_id = tunnel_info["pid"]
|
instance.tunnel_id = tunnel_info["pid"]
|
||||||
instance.public_url = tunnel_info["url"]
|
instance.public_url = tunnel_info["url"]
|
||||||
instance.url = tunnel_info["url"]
|
instance.url = tunnel_info["url"]
|
||||||
logger.info(
|
logger.debug(
|
||||||
"Created new tunnel for instance %s: %s",
|
"Created new tunnel for instance %s: %s",
|
||||||
instance.id,
|
instance.id,
|
||||||
tunnel_info["url"],
|
tunnel_info["url"],
|
||||||
@@ -1397,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):
|
||||||
@@ -1416,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.info("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):
|
||||||
@@ -1429,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)
|
||||||
@@ -1525,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(
|
||||||
@@ -1541,7 +1779,7 @@ async def recreate_tunnel_endpoint(
|
|||||||
instance.public_url = tunnel_info["url"]
|
instance.public_url = tunnel_info["url"]
|
||||||
instance.url = tunnel_info["url"]
|
instance.url = tunnel_info["url"]
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(
|
logger.debug(
|
||||||
"Recreated tunnel for instance %s: pid=%s, url=%s",
|
"Recreated tunnel for instance %s: pid=%s, url=%s",
|
||||||
instance.id,
|
instance.id,
|
||||||
tunnel_info["pid"],
|
tunnel_info["pid"],
|
||||||
@@ -1610,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
|
||||||
@@ -1769,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",
|
||||||
@@ -1796,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()
|
||||||
@@ -1807,7 +2050,8 @@ 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),
|
"id": str(instance.id),
|
||||||
"display_name": instance.display_name,
|
"display_name": instance.display_name,
|
||||||
"tool_type_name": tool_type.name if tool_type else "unknown",
|
"tool_type_name": tool_type.name if tool_type else "unknown",
|
||||||
@@ -1821,8 +2065,13 @@ async def get_user_sessions(
|
|||||||
"url": instance.url,
|
"url": instance.url,
|
||||||
"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)
|
||||||
"created_at": instance.created_at.isoformat() if instance.created_at else None,
|
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"])
|
||||||
|
|
||||||
|
|
||||||
@@ -103,11 +102,11 @@ async def update_user_config(
|
|||||||
|
|
||||||
# Merge updates
|
# Merge updates
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
logger.info("Updating user config for user %s: %s", user_id, update_data)
|
logger.debug("Updating user config for user %s: %s", user_id, update_data)
|
||||||
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
||||||
config.config = {**config.config, **update_data}
|
config.config = {**config.config, **update_data}
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
logger.info("Updated config: %s", config.config)
|
logger.debug("Updated config: %s", config.config)
|
||||||
return UserConfigResponse.model_validate(config.config)
|
return UserConfigResponse.model_validate(config.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
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def clone_repository(
|
|||||||
str(clone_path),
|
str(clone_path),
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
|
logger.debug("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
cmd,
|
cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -55,7 +55,7 @@ def clone_repository(
|
|||||||
logger.error("Git clone failed: %s", result.stderr)
|
logger.error("Git clone failed: %s", result.stderr)
|
||||||
raise RuntimeError(f"Failed to clone repository: {result.stderr}")
|
raise RuntimeError(f"Failed to clone repository: {result.stderr}")
|
||||||
|
|
||||||
logger.info("Successfully cloned repository into %s", clone_path)
|
logger.debug("Successfully cloned repository into %s", clone_path)
|
||||||
return str(clone_path)
|
return str(clone_path)
|
||||||
|
|
||||||
|
|
||||||
@@ -94,4 +94,4 @@ def remove_clone_directory(instance_dir: str) -> None:
|
|||||||
if clone_path.exists():
|
if clone_path.exists():
|
||||||
import shutil
|
import shutil
|
||||||
shutil.rmtree(clone_path)
|
shutil.rmtree(clone_path)
|
||||||
logger.info("Removed clone directory: %s", clone_path)
|
logger.debug("Removed clone directory: %s", clone_path)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ def ensure_instance_directory(instance_id: str, base_path: str | None = None) ->
|
|||||||
"""
|
"""
|
||||||
if base_path is None:
|
if base_path is None:
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
|
|
||||||
base_path = Settings().instance_base_path
|
base_path = Settings().instance_base_path
|
||||||
instance_dir = Path(base_path) / instance_id
|
instance_dir = Path(base_path) / instance_id
|
||||||
instance_dir.mkdir(parents=True, exist_ok=True)
|
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -136,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
|
||||||
|
|
||||||
@@ -143,7 +148,7 @@ def get_container_id(instance_name: str) -> str | None:
|
|||||||
Container ID or None if not found
|
Container ID or None if not found
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
|
["docker", "ps", "-a", "-q", "--filter", f"name={instance_name}"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
@@ -156,6 +161,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
|
||||||
|
|
||||||
@@ -163,7 +170,15 @@ def get_container_name(instance_name: str) -> str | None:
|
|||||||
Container name or None if not found
|
Container name or None if not found
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
|
[
|
||||||
|
"docker",
|
||||||
|
"ps",
|
||||||
|
"-a",
|
||||||
|
"--format",
|
||||||
|
"{{.Names}}",
|
||||||
|
"--filter",
|
||||||
|
f"name={instance_name}",
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
@@ -173,7 +188,9 @@ def get_container_name(instance_name: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
|
def connect_container_to_network(
|
||||||
|
container_name: str, network_name: str = "backend"
|
||||||
|
) -> bool:
|
||||||
"""Connect a Docker container to an existing network.
|
"""Connect a Docker container to an existing network.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -203,7 +220,9 @@ def get_container_status(container_id: str) -> dict[str, Any]:
|
|||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[
|
[
|
||||||
"docker", "inspect", "-f",
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
"-f",
|
||||||
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
||||||
container_id,
|
container_id,
|
||||||
],
|
],
|
||||||
@@ -238,7 +257,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()
|
||||||
|
|
||||||
@@ -322,11 +340,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]:
|
||||||
@@ -344,8 +357,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__)
|
||||||
@@ -354,18 +365,29 @@ def start_cloudflared_tunnel(
|
|||||||
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
||||||
for attempt in range(10):
|
for attempt in range(10):
|
||||||
check = subprocess.run(
|
check = subprocess.run(
|
||||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
[
|
||||||
f"http://{container_name}:{port}"],
|
"curl",
|
||||||
|
"-s",
|
||||||
|
"-o",
|
||||||
|
"/dev/null",
|
||||||
|
"-w",
|
||||||
|
"%{http_code}",
|
||||||
|
f"http://{container_name}:{port}",
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
|
logger.info(
|
||||||
|
"Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip()
|
||||||
|
)
|
||||||
if check.returncode == 0:
|
if check.returncode == 0:
|
||||||
break
|
break
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
else:
|
else:
|
||||||
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
|
logger.warning(
|
||||||
|
"Container %s:%d not responding to curl checks", container_name, port
|
||||||
|
)
|
||||||
|
|
||||||
# Run cloudflared in background, capture output
|
# Run cloudflared in background, capture output
|
||||||
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
||||||
@@ -384,6 +406,7 @@ def start_cloudflared_tunnel(
|
|||||||
while time.time() - start_time < timeout:
|
while time.time() - start_time < timeout:
|
||||||
# Read available output
|
# Read available output
|
||||||
import select
|
import select
|
||||||
|
|
||||||
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
|
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
|
||||||
if readable:
|
if readable:
|
||||||
line = proc.stdout.readline()
|
line = proc.stdout.readline()
|
||||||
@@ -410,7 +433,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:
|
||||||
@@ -455,8 +477,17 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
[
|
||||||
"--max-time", str(timeout), url],
|
"curl",
|
||||||
|
"-s",
|
||||||
|
"-o",
|
||||||
|
"/dev/null",
|
||||||
|
"-w",
|
||||||
|
"%{http_code}",
|
||||||
|
"--max-time",
|
||||||
|
str(timeout),
|
||||||
|
url,
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout + 5,
|
timeout=timeout + 5,
|
||||||
@@ -495,7 +526,15 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
|||||||
except (ValueError, Exception) as e:
|
except (ValueError, Exception) as e:
|
||||||
error_str = str(e).lower()
|
error_str = str(e).lower()
|
||||||
# Classify connection errors
|
# Classify connection errors
|
||||||
if any(err in error_str for err in ["connection refused", "econnrefused", "could not resolve", "nodename"]):
|
if any(
|
||||||
|
err in error_str
|
||||||
|
for err in [
|
||||||
|
"connection refused",
|
||||||
|
"econnrefused",
|
||||||
|
"could not resolve",
|
||||||
|
"nodename",
|
||||||
|
]
|
||||||
|
):
|
||||||
return {
|
return {
|
||||||
"tunnel_status": "unreachable",
|
"tunnel_status": "unreachable",
|
||||||
"status_code": None,
|
"status_code": None,
|
||||||
|
|||||||
@@ -18,13 +18,12 @@ 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
|
||||||
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
||||||
dockerfile_path.write_text(dockerfile)
|
dockerfile_path.write_text(dockerfile)
|
||||||
logger.info("Wrote Dockerfile to %s", dockerfile_path)
|
logger.debug("Wrote Dockerfile to %s", dockerfile_path)
|
||||||
|
|
||||||
# Write build context files
|
# Write build context files
|
||||||
if build_context:
|
if build_context:
|
||||||
@@ -39,10 +38,10 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
full_path.write_text(content)
|
full_path.write_text(content)
|
||||||
logger.info("Wrote build context file: %s", full_path)
|
logger.debug("Wrote build context file: %s", full_path)
|
||||||
|
|
||||||
# Build image
|
# Build image
|
||||||
logger.info("Building Docker image with tag: %s", tag)
|
logger.debug("Building Docker image with tag: %s", tag)
|
||||||
cmd = [
|
cmd = [
|
||||||
"docker", "build",
|
"docker", "build",
|
||||||
"-t", tag,
|
"-t", tag,
|
||||||
@@ -57,7 +56,7 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=300, # 5 minute timeout for builds
|
timeout=300, # 5 minute timeout for builds
|
||||||
)
|
)
|
||||||
logger.info("Docker build completed: returncode=%d", result.returncode)
|
logger.debug("Docker build completed: returncode=%d", result.returncode)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
logger.error("Docker build failed: %s", result.stderr[:1000])
|
logger.error("Docker build failed: %s", result.stderr[:1000])
|
||||||
return result.returncode, result.stdout, result.stderr
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -72,11 +71,11 @@ class TerminalManager:
|
|||||||
|
|
||||||
# Check if session is still alive
|
# Check if session is still alive
|
||||||
if session.is_alive():
|
if session.is_alive():
|
||||||
logger.info("Reattaching to existing terminal session for instance %s", instance_id)
|
logger.debug("Reattaching to existing terminal session for instance %s", instance_id)
|
||||||
return session
|
return session
|
||||||
else:
|
else:
|
||||||
# Session died, clean it up
|
# Session died, clean it up
|
||||||
logger.info("Existing session for instance %s is dead, cleaning up", instance_id)
|
logger.debug("Existing session for instance %s is dead, cleaning up", instance_id)
|
||||||
await session.close()
|
await session.close()
|
||||||
del self._sessions[instance_id_str]
|
del self._sessions[instance_id_str]
|
||||||
|
|
||||||
@@ -97,7 +96,7 @@ class TerminalManager:
|
|||||||
"""Attach a WebSocket to an existing session."""
|
"""Attach a WebSocket to an existing session."""
|
||||||
# Handle concurrent connections - close existing ones
|
# Handle concurrent connections - close existing ones
|
||||||
if session.has_websockets():
|
if session.has_websockets():
|
||||||
logger.info("Closing existing WebSocket connections for instance %s", session.instance_id)
|
logger.debug("Closing existing WebSocket connections for instance %s", session.instance_id)
|
||||||
for ws in list(session._websockets):
|
for ws in list(session._websockets):
|
||||||
try:
|
try:
|
||||||
await ws.close(code=4000, reason="New connection established")
|
await ws.close(code=4000, reason="New connection established")
|
||||||
@@ -135,7 +134,7 @@ class TerminalManager:
|
|||||||
|
|
||||||
# Close existing session if any
|
# Close existing session if any
|
||||||
if instance_id_str in self._sessions:
|
if instance_id_str in self._sessions:
|
||||||
logger.info("Resetting terminal session for instance %s", instance_id)
|
logger.debug("Resetting terminal session for instance %s", instance_id)
|
||||||
old_session = self._sessions.pop(instance_id_str)
|
old_session = self._sessions.pop(instance_id_str)
|
||||||
await old_session.close()
|
await old_session.close()
|
||||||
|
|
||||||
|
|||||||
@@ -60,12 +60,12 @@ class TerminalSession:
|
|||||||
|
|
||||||
# Set the terminal size initially
|
# Set the terminal size initially
|
||||||
self._set_terminal_size(self._cols, self._rows)
|
self._set_terminal_size(self._cols, self._rows)
|
||||||
logger.info(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}")
|
logger.debug(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}")
|
||||||
|
|
||||||
# Build the shell command
|
# Build the shell command
|
||||||
if startup_command:
|
if startup_command:
|
||||||
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
|
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
|
||||||
logger.info(f"Using startup command for session {self.session_id}: {startup_command}")
|
logger.debug(f"Using startup command for session {self.session_id}: {startup_command}")
|
||||||
else:
|
else:
|
||||||
shell_cmd = "bash -il"
|
shell_cmd = "bash -il"
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ class TerminalSession:
|
|||||||
size = struct.pack('HHHH', rows, cols, 0, 0)
|
size = struct.pack('HHHH', rows, cols, 0, 0)
|
||||||
try:
|
try:
|
||||||
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
||||||
logger.info(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
|
logger.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
|
||||||
except (OSError, IOError) as e:
|
except (OSError, IOError) as e:
|
||||||
logger.error(f"Failed to resize PTY: {e}")
|
logger.error(f"Failed to resize PTY: {e}")
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ class TerminalSession:
|
|||||||
|
|
||||||
self._cols = cols
|
self._cols = cols
|
||||||
self._rows = rows
|
self._rows = rows
|
||||||
logger.info(f"resize() called for session {self.session_id}: {cols}x{rows}")
|
logger.debug(f"resize() called for session {self.session_id}: {cols}x{rows}")
|
||||||
self._set_terminal_size(cols, rows)
|
self._set_terminal_size(cols, rows)
|
||||||
|
|
||||||
# Docker exec -it creates its own PTY inside the container,
|
# Docker exec -it creates its own PTY inside the container,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -402,8 +402,8 @@ class TestConfigProfilesAPI:
|
|||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_create_config_profile_invalid_git_mount_target_path(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
def test_create_config_profile_invalid_git_mount_target_path_traversal(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
"""Test that invalid git mount target paths are rejected."""
|
"""Test that git mount target paths with traversal are rejected."""
|
||||||
_project_id, repo_id = test_project_and_repo
|
_project_id, repo_id = test_project_and_repo
|
||||||
|
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
@@ -416,7 +416,7 @@ class TestConfigProfilesAPI:
|
|||||||
{
|
{
|
||||||
"remote_url": "https://github.com/user/repo.git",
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
"source_path": ".",
|
"source_path": ".",
|
||||||
"target_path": "relative/path",
|
"target_path": "../../../etc/passwd",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,8 @@ export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
|||||||
branch: "",
|
branch: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = (mount: GitMount) => {
|
||||||
if (!newMount.remote_url || !newMount.target_path) return;
|
onChange([...mounts, mount]);
|
||||||
onChange([...mounts, { ...newMount }]);
|
|
||||||
setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" });
|
setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,7 +35,6 @@ export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
|||||||
const validatePath = (path: string, isTarget: boolean): string | null => {
|
const validatePath = (path: string, isTarget: boolean): string | null => {
|
||||||
if (!path) return isTarget ? "Target path is required" : null;
|
if (!path) return isTarget ? "Target path is required" : null;
|
||||||
if (path.includes("..")) return "Path cannot contain ..";
|
if (path.includes("..")) return "Path cannot contain ..";
|
||||||
if (isTarget && !path.startsWith("/")) return "Target path must be absolute";
|
|
||||||
if (!isTarget && path.startsWith("/")) return "Source path must be relative";
|
if (!isTarget && path.startsWith("/")) return "Source path must be relative";
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
@@ -200,7 +198,7 @@ const GitMountForm = ({ mount, onSave, onCancel, validatePath, validateUrl, isNe
|
|||||||
placeholder="e.g., /app/config"
|
placeholder="e.g., /app/config"
|
||||||
className={errors.target_path ? "error" : ""}
|
className={errors.target_path ? "error" : ""}
|
||||||
/>
|
/>
|
||||||
<span className="hint">Absolute path inside container</span>
|
<span className="hint">Use absolute path (e.g. /app/config). Relative paths need working_directory set in tool config.</span>
|
||||||
{errors.target_path && <span className="error-text">{errors.target_path}</span>}
|
{errors.target_path && <span className="error-text">{errors.target_path}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,6 @@ export const Icon: React.FC<IconProps> = ({
|
|||||||
const sizeValue = sizeMap[size];
|
const sizeValue = sizeMap[size];
|
||||||
|
|
||||||
if (!IconComponent) {
|
if (!IconComponent) {
|
||||||
console.warn(`Icon "${name}" not found`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { FitAddon } from "xterm-addon-fit";
|
|||||||
import { WebLinksAddon } from "xterm-addon-web-links";
|
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||||
import "xterm/css/xterm.css";
|
import "xterm/css/xterm.css";
|
||||||
|
|
||||||
import { applyModifierToChar, type ModifierKey } from "../hooks/use-special-keys";
|
import {
|
||||||
|
applyModifierToChar,
|
||||||
|
type ModifierKey,
|
||||||
|
} from "../hooks/use-special-keys";
|
||||||
|
|
||||||
interface TerminalProps {
|
interface TerminalProps {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
@@ -14,9 +17,14 @@ interface TerminalProps {
|
|||||||
onModifierChange?: (modifier: ModifierKey | null) => void;
|
onModifierChange?: (modifier: ModifierKey | null) => void;
|
||||||
onTerminalReady?: (
|
onTerminalReady?: (
|
||||||
sendData: (data: string) => void,
|
sendData: (data: string) => void,
|
||||||
connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting",
|
connectionStatus:
|
||||||
|
| "connecting"
|
||||||
|
| "connected"
|
||||||
|
| "disconnected"
|
||||||
|
| "error"
|
||||||
|
| "resetting",
|
||||||
focusInput: () => void,
|
focusInput: () => void,
|
||||||
changeFontSize: (delta: number) => void
|
changeFontSize: (delta: number) => void,
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +70,7 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
const lastPingRef = useRef<number>(0);
|
const lastPingRef = useRef<number>(0);
|
||||||
const heartbeatCheckRef = useRef<number | null>(null);
|
const heartbeatCheckRef = useRef<number | null>(null);
|
||||||
const isUnmountingRef = useRef(false);
|
const isUnmountingRef = useRef(false);
|
||||||
|
const permanentErrorRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const calculateFontSize = useCallback(() => {
|
const calculateFontSize = useCallback(() => {
|
||||||
return fontSize;
|
return fontSize;
|
||||||
@@ -73,12 +82,11 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||||
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
||||||
|
|
||||||
console.log(`[Terminal WS] Connecting to ${wsUrl} (attempt ${reconnectAttemptsRef.current + 1}/${RECONNECT_ATTEMPTS + 1})`);
|
// WebSocket connection established
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
console.log(`[Terminal WS] Connected successfully`);
|
|
||||||
setStatus("connected");
|
setStatus("connected");
|
||||||
setError(null);
|
setError(null);
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
@@ -99,10 +107,8 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
}
|
}
|
||||||
heartbeatCheckRef.current = window.setInterval(() => {
|
heartbeatCheckRef.current = window.setInterval(() => {
|
||||||
const elapsed = Date.now() - lastPingRef.current;
|
const elapsed = Date.now() - lastPingRef.current;
|
||||||
console.log(`[Terminal WS] Heartbeat check: lastPing=${elapsed}ms ago`);
|
|
||||||
if (elapsed > 60000) {
|
if (elapsed > 60000) {
|
||||||
// No ping for 60 seconds, connection may be dead
|
// No ping for 60 seconds, connection may be dead
|
||||||
console.warn("[Terminal WS] Heartbeat timeout (>60s), closing connection");
|
|
||||||
ws.close(4000, "Heartbeat timeout");
|
ws.close(4000, "Heartbeat timeout");
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
@@ -132,7 +138,9 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
const { cols, rows } = termRef.current;
|
const { cols, rows } = termRef.current;
|
||||||
const currentWs = wsRef.current;
|
const currentWs = wsRef.current;
|
||||||
if (currentWs?.readyState === WebSocket.OPEN) {
|
if (currentWs?.readyState === WebSocket.OPEN) {
|
||||||
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
|
currentWs.send(
|
||||||
|
JSON.stringify({ type: "resize", cols, rows }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -143,7 +151,6 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
} else if (msg.type === "ping") {
|
} else if (msg.type === "ping") {
|
||||||
// Respond with pong and update last ping time
|
// Respond with pong and update last ping time
|
||||||
lastPingRef.current = Date.now();
|
lastPingRef.current = Date.now();
|
||||||
console.log(`[Terminal WS] Received ping, sending pong`);
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: "pong" }));
|
ws.send(JSON.stringify({ type: "pong" }));
|
||||||
}
|
}
|
||||||
@@ -155,46 +162,52 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
console.log(`[Terminal WS] Connection closed: code=${event.code}, reason="${event.reason}", wasClean=${event.wasClean}, attempts=${reconnectAttemptsRef.current}`);
|
|
||||||
setStatus("disconnected");
|
|
||||||
|
|
||||||
// Clean up heartbeat check
|
// Clean up heartbeat check
|
||||||
if (heartbeatCheckRef.current) {
|
if (heartbeatCheckRef.current) {
|
||||||
window.clearInterval(heartbeatCheckRef.current);
|
window.clearInterval(heartbeatCheckRef.current);
|
||||||
heartbeatCheckRef.current = null;
|
heartbeatCheckRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.code !== 1000 && event.code !== 4000) {
|
// Permanent errors: do not retry
|
||||||
|
if (event.code === 4001 || event.code === 4003 || event.code === 4004) {
|
||||||
|
const reason = event.reason || `Instance error (code: ${event.code})`;
|
||||||
|
setStatus("error");
|
||||||
|
setError(reason);
|
||||||
|
permanentErrorRef.current = reason;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.code === 1000) {
|
||||||
|
setStatus("disconnected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.code === 4000) {
|
||||||
|
// Server closed old connection for concurrent connection - don't reconnect
|
||||||
|
// The new connection is already established
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transient errors: attempt reconnection
|
||||||
|
setStatus("disconnected");
|
||||||
setError(`Connection closed (code: ${event.code})`);
|
setError(`Connection closed (code: ${event.code})`);
|
||||||
|
|
||||||
// Attempt reconnection
|
|
||||||
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
|
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
|
||||||
reconnectAttemptsRef.current++;
|
reconnectAttemptsRef.current++;
|
||||||
const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
|
const delay =
|
||||||
console.log(`[Terminal WS] Will retry in ${delay}ms (attempt ${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})`);
|
RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (isUnmountingRef.current) {
|
if (isUnmountingRef.current) {
|
||||||
console.log(`[Terminal WS] Component unmounting, skipping reconnect`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (document.visibilityState !== "hidden") {
|
if (document.visibilityState !== "hidden") {
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
} else {
|
|
||||||
console.log(`[Terminal WS] Tab hidden, skipping reconnect`);
|
|
||||||
}
|
}
|
||||||
}, delay);
|
}, delay);
|
||||||
} else {
|
|
||||||
console.log(`[Terminal WS] Max reconnection attempts (${RECONNECT_ATTEMPTS}) reached`);
|
|
||||||
}
|
|
||||||
} else if (event.code === 4000) {
|
|
||||||
// Server closed old connection for concurrent connection - don't reconnect
|
|
||||||
// The new connection is already established
|
|
||||||
console.log(`[Terminal WS] Server closed old connection (concurrent/heartbeat)`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
ws.onerror = () => {
|
||||||
console.error(`[Terminal WS] Error event fired`, error);
|
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setError("WebSocket error");
|
setError("WebSocket error");
|
||||||
};
|
};
|
||||||
@@ -250,8 +263,6 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
// Define fitTerminal before connectWebSocket so it's available in onmessage
|
// Define fitTerminal before connectWebSocket so it's available in onmessage
|
||||||
const fitTerminal = () => {
|
const fitTerminal = () => {
|
||||||
if (!fitAddonRef.current || !termRef.current) return;
|
if (!fitAddonRef.current || !termRef.current) return;
|
||||||
const oldCols = termRef.current.cols;
|
|
||||||
const oldRows = termRef.current.rows;
|
|
||||||
try {
|
try {
|
||||||
fitAddonRef.current.fit();
|
fitAddonRef.current.fit();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -259,7 +270,6 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { cols, rows } = termRef.current;
|
const { cols, rows } = termRef.current;
|
||||||
console.log(`[Terminal] fit() result: ${cols}x${rows} (was ${oldCols}x${oldRows})`);
|
|
||||||
// Force refresh if dimensions are valid
|
// Force refresh if dimensions are valid
|
||||||
if (cols > 0 && rows > 0) {
|
if (cols > 0 && rows > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -285,14 +295,10 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
fitAttempts++;
|
fitAttempts++;
|
||||||
// Ensure container has dimensions before fitting
|
// Ensure container has dimensions before fitting
|
||||||
if (container.clientWidth > 0 && container.clientHeight > 0) {
|
if (container.clientWidth > 0 && container.clientHeight > 0) {
|
||||||
console.log(`[Terminal] Container ready: ${container.clientWidth}x${container.clientHeight} (attempt ${fitAttempts})`);
|
|
||||||
fitTerminal();
|
fitTerminal();
|
||||||
} else if (fitAttempts < 50) {
|
} else if (fitAttempts < 50) {
|
||||||
// Container not ready yet, try again (max 50 attempts ~ 1s)
|
// Container not ready yet, try again (max 50 attempts ~ 1s)
|
||||||
console.log(`[Terminal] Container not ready: ${container.clientWidth}x${container.clientHeight} (attempt ${fitAttempts})`);
|
|
||||||
requestAnimationFrame(doInitialFit);
|
requestAnimationFrame(doInitialFit);
|
||||||
} else {
|
|
||||||
console.warn(`[Terminal] Container never got dimensions after ${fitAttempts} attempts`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
requestAnimationFrame(doInitialFit);
|
requestAnimationFrame(doInitialFit);
|
||||||
@@ -379,9 +385,14 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
|
|
||||||
// Visibility API for reconnection
|
// Visibility API for reconnection
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
console.log(`[Terminal WS] Visibility changed to: ${document.visibilityState}, wsState=${ws?.readyState}`);
|
if (
|
||||||
if (document.visibilityState === "visible" && ws && ws.readyState !== WebSocket.OPEN) {
|
document.visibilityState === "visible" &&
|
||||||
console.log(`[Terminal WS] Tab visible, resetting reconnect attempts and reconnecting`);
|
ws &&
|
||||||
|
ws.readyState !== WebSocket.OPEN
|
||||||
|
) {
|
||||||
|
if (permanentErrorRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
}
|
}
|
||||||
@@ -405,7 +416,6 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
}
|
}
|
||||||
term.dispose();
|
term.dispose();
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [instanceId, connectWebSocket]);
|
}, [instanceId, connectWebSocket]);
|
||||||
|
|
||||||
// Update parent about status changes
|
// Update parent about status changes
|
||||||
@@ -427,7 +437,10 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
}, [status, onTerminalReady]);
|
}, [status, onTerminalReady]);
|
||||||
|
|
||||||
const handleFontSizeChange = (delta: number) => {
|
const handleFontSizeChange = (delta: number) => {
|
||||||
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize + delta));
|
const newSize = Math.max(
|
||||||
|
MIN_FONT_SIZE,
|
||||||
|
Math.min(MAX_FONT_SIZE, fontSize + delta),
|
||||||
|
);
|
||||||
setFontSize(newSize);
|
setFontSize(newSize);
|
||||||
localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
|
localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
|
||||||
if (termRef.current && fitAddonRef.current) {
|
if (termRef.current && fitAddonRef.current) {
|
||||||
@@ -443,7 +456,7 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
type: "resize",
|
type: "resize",
|
||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -564,7 +577,10 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
{showResetConfirm && (
|
{showResetConfirm && (
|
||||||
<div className="terminal-reset-confirm">
|
<div className="terminal-reset-confirm">
|
||||||
<div className="terminal-reset-confirm-content">
|
<div className="terminal-reset-confirm-content">
|
||||||
<p>Reset terminal? This will kill the current shell session and start fresh.</p>
|
<p>
|
||||||
|
Reset terminal? This will kill the current shell session and start
|
||||||
|
fresh.
|
||||||
|
</p>
|
||||||
<div className="terminal-reset-confirm-buttons">
|
<div className="terminal-reset-confirm-buttons">
|
||||||
<button
|
<button
|
||||||
className="terminal-reset-confirm-button cancel"
|
className="terminal-reset-confirm-button cancel"
|
||||||
|
|||||||
@@ -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>>({});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ The system SHALL allow config profiles to include git repository mounts that bin
|
|||||||
- **THEN** the system validates that:
|
- **THEN** the system validates that:
|
||||||
- `remote_url` is a valid git URL (starts with https://, git@, or ssh://)
|
- `remote_url` is a valid git URL (starts with https://, git@, or ssh://)
|
||||||
- `source_path` is a relative path (no leading `/`)
|
- `source_path` is a relative path (no leading `/`)
|
||||||
- `target_path` is an absolute path (starts with `/`)
|
- `target_path` can be absolute (starts with `/`) or relative (resolved against working directory, defaulting to `/home/user`)
|
||||||
- `target_path` does not contain path traversal sequences (`..`)
|
- `target_path` does not contain path traversal sequences (`..`)
|
||||||
- No database lookup or repository existence check is performed (validation is deferred to clone time)
|
- No database lookup or repository existence check is performed (validation is deferred to clone time)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Tool Images
|
||||||
|
|
||||||
|
This directory contains Dockerfile templates for the base tool types used in Headquarter.
|
||||||
|
|
||||||
|
## Available Images
|
||||||
|
|
||||||
|
| File | Tool | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `base.dockerfile` | - | Common base with git, nvim, ranger, tmux, node |
|
||||||
|
| `code-server.dockerfile` | VS Code | Browser-based VS Code with extra tools |
|
||||||
|
| `jupyter.dockerfile` | Jupyter | Jupyter Lab/Notebook with extra tools |
|
||||||
|
| `opencode.dockerfile` | OpenCode | OpenCode agent server |
|
||||||
|
| `pi-agent.dockerfile` | Pi Agent | Pi coding agent with full terminal setup |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
When creating a tool type in the Tool Workshop, you can reference these Dockerfiles:
|
||||||
|
|
||||||
|
1. Copy the contents of the desired `.dockerfile`
|
||||||
|
2. Paste into the "Dockerfile Template" field
|
||||||
|
3. Set `definition_type` to `dockerfile`
|
||||||
|
|
||||||
|
## Building Locally
|
||||||
|
|
||||||
|
To test an image locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tool-images
|
||||||
|
docker build -f pi-agent.dockerfile -t pi-agent:latest .
|
||||||
|
docker run -it pi-agent:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Customizing
|
||||||
|
|
||||||
|
All images include:
|
||||||
|
- **git** - Version control
|
||||||
|
- **neovim** - Terminal editor
|
||||||
|
- **ranger** - Terminal file manager
|
||||||
|
- **tmux** - Terminal multiplexer
|
||||||
|
- **htop** - Process viewer
|
||||||
|
- **tree** - Directory tree
|
||||||
|
- **jq** - JSON processor
|
||||||
|
- **Node.js 20** - For npm-based tools
|
||||||
|
|
||||||
|
The `pi-agent` image additionally includes the [Pi Coding Agent](https://pi.dev/) for AI-assisted development.
|
||||||
|
|
||||||
|
## Adding New Images
|
||||||
|
|
||||||
|
1. Create a new `.dockerfile` in this directory
|
||||||
|
2. Use `base.dockerfile` as a starting point if applicable
|
||||||
|
3. Document it in the table above
|
||||||
|
4. Update the tool type in the database via the Tool Workshop
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Base development image with common tools
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
# Prevent interactive prompts during apt install
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install base tools
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
git \
|
||||||
|
neovim \
|
||||||
|
ranger \
|
||||||
|
tmux \
|
||||||
|
htop \
|
||||||
|
tree \
|
||||||
|
jq \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create a non-root user
|
||||||
|
RUN useradd -m -s /bin/bash user
|
||||||
|
WORKDIR /home/user
|
||||||
|
|
||||||
|
# Install Node.js (needed for pi and many dev tools)
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set up git configuration defaults
|
||||||
|
RUN git config --global init.defaultBranch main \
|
||||||
|
&& git config --global user.email "dev@headquarter.local" \
|
||||||
|
&& git config --global user.name "Developer"
|
||||||
|
|
||||||
|
USER user
|
||||||
|
CMD ["/bin/bash"]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# VS Code in browser
|
||||||
|
FROM lscr.io/linuxserver/code-server:latest
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Install additional tools
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
git \
|
||||||
|
neovim \
|
||||||
|
ranger \
|
||||||
|
tmux \
|
||||||
|
htop \
|
||||||
|
tree \
|
||||||
|
jq \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set up git
|
||||||
|
RUN git config --global init.defaultBranch main
|
||||||
|
|
||||||
|
# Code-server runs as abc user by default
|
||||||
|
USER abc
|
||||||
|
|
||||||
|
EXPOSE 8443
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Jupyter Notebook/Lab
|
||||||
|
FROM jupyter/scipy-notebook:latest
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Install additional tools
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
git \
|
||||||
|
neovim \
|
||||||
|
ranger \
|
||||||
|
tmux \
|
||||||
|
htop \
|
||||||
|
tree \
|
||||||
|
jq \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set up git
|
||||||
|
RUN git config --global init.defaultBranch main
|
||||||
|
|
||||||
|
# Switch back to jovyan user (default for scipy-notebook)
|
||||||
|
USER ${NB_UID}
|
||||||
|
|
||||||
|
EXPOSE 8888
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# OpenCode agent environment
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install base dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
git \
|
||||||
|
neovim \
|
||||||
|
ranger \
|
||||||
|
tmux \
|
||||||
|
htop \
|
||||||
|
tree \
|
||||||
|
jq \
|
||||||
|
ca-certificates \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install OpenCode
|
||||||
|
RUN npm install -g opencode
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -s /bin/bash user
|
||||||
|
WORKDIR /home/user
|
||||||
|
|
||||||
|
# Set up git
|
||||||
|
RUN git config --global init.defaultBranch main
|
||||||
|
|
||||||
|
USER user
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["opencode", "server"]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Pi Coding Agent - Terminal-based coding harness
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install base dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
git \
|
||||||
|
neovim \
|
||||||
|
ranger \
|
||||||
|
tmux \
|
||||||
|
htop \
|
||||||
|
tree \
|
||||||
|
jq \
|
||||||
|
ca-certificates \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js (required for Pi)
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Pi Coding Agent globally
|
||||||
|
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -s /bin/bash user
|
||||||
|
WORKDIR /home/user
|
||||||
|
|
||||||
|
# Set up git
|
||||||
|
RUN git config --global init.defaultBranch main \
|
||||||
|
&& git config --global user.email "dev@headquarter.local" \
|
||||||
|
&& git config --global user.name "Developer"
|
||||||
|
|
||||||
|
# Create default tmux config
|
||||||
|
RUN echo 'set -g mouse on\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||||
|
|
||||||
|
# Create default ranger config
|
||||||
|
RUN mkdir -p /home/user/.config/ranger \
|
||||||
|
&& echo 'set preview_files true\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
|
||||||
|
|
||||||
|
# Set up Pi config directory
|
||||||
|
RUN mkdir -p /home/user/.pi/agent
|
||||||
|
|
||||||
|
USER user
|
||||||
|
|
||||||
|
# Default to bash (Pi is invoked manually via `pi` command)
|
||||||
|
CMD ["/bin/bash"]
|
||||||
Reference in New Issue
Block a user