merge: integrate main restructuring into dev
- Resolve 57 merge conflicts from codebase restructure - Port dev feature code to new directory structure: * Update import paths to use @/ aliases * Add backward-compatible API signatures (createInstance, startInstance, deleteInstance) * Add missing type exports (ProjectWithRepos, InstanceHealth, Branch, BranchesResponse) * Extend Session and GitRepository types for dev features * Extend TerminalComponent props for mobile terminal wrapper * Add missing icon names (bell, drag, undo) Quality gates: tsc pass (0 errors), build pass, 127/131 tests pass (4 pre-existing failures unrelated to merge)
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
"""Config profile business logic."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.models.config_include import ConfigInclude
|
||||
from src.models.config_mount import ConfigMount
|
||||
from src.models.config_profile import ConfigProfile
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_INCLUDES_DEPTH = 10
|
||||
|
||||
|
||||
async def get_owned_profile(
|
||||
profile_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
) -> ConfigProfile:
|
||||
"""Fetch a config profile and verify ownership."""
|
||||
profile = await session.get(ConfigProfile, profile_id)
|
||||
if profile is None or profile.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="config profile not found",
|
||||
)
|
||||
return profile
|
||||
|
||||
|
||||
async def _detect_cycle(
|
||||
session: AsyncSession,
|
||||
profile_id: uuid.UUID,
|
||||
visited: set[uuid.UUID] | None = None,
|
||||
depth: int = 0,
|
||||
) -> bool:
|
||||
"""Detect cycles in profile includes using DFS.
|
||||
|
||||
Returns True if a cycle is detected.
|
||||
"""
|
||||
if depth > MAX_INCLUDES_DEPTH:
|
||||
return True
|
||||
|
||||
if visited is None:
|
||||
visited = set()
|
||||
|
||||
if profile_id in visited:
|
||||
return True
|
||||
|
||||
visited.add(profile_id)
|
||||
|
||||
result = await session.execute(
|
||||
select(ConfigInclude.included_profile_id).where(
|
||||
ConfigInclude.profile_id == profile_id
|
||||
)
|
||||
)
|
||||
included_ids = result.scalars().all()
|
||||
|
||||
for included_id in included_ids:
|
||||
if await _detect_cycle(session, included_id, visited.copy(), depth + 1):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def validate_includes_no_cycle(
|
||||
session: AsyncSession,
|
||||
profile_id: uuid.UUID,
|
||||
new_included_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Validate that adding an include wouldn't create a cycle."""
|
||||
if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="adding this include would create a circular reference",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profile CRUD helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def check_duplicate_name(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
name: str,
|
||||
exclude_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Raise 409 if a profile with the given name already exists."""
|
||||
query = select(ConfigProfile).where(
|
||||
ConfigProfile.user_id == user_id,
|
||||
ConfigProfile.name == name,
|
||||
)
|
||||
if exclude_id:
|
||||
query = query.where(ConfigProfile.id != exclude_id)
|
||||
existing = await session.scalar(query)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"config profile with name '{name}' already exists",
|
||||
)
|
||||
|
||||
|
||||
def profile_to_dict(profile: ConfigProfile) -> dict:
|
||||
"""Serialize a ConfigProfile to a dict."""
|
||||
return {
|
||||
"id": str(profile.id),
|
||||
"user_id": str(profile.user_id),
|
||||
"name": profile.name,
|
||||
"description": profile.description,
|
||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Include helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def check_duplicate_include(
|
||||
session: AsyncSession,
|
||||
profile_id: uuid.UUID,
|
||||
included_profile_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Raise 409 if the include already exists."""
|
||||
existing = await session.scalar(
|
||||
select(ConfigInclude).where(
|
||||
ConfigInclude.profile_id == profile_id,
|
||||
ConfigInclude.included_profile_id == included_profile_id,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="this include already exists",
|
||||
)
|
||||
|
||||
|
||||
def include_to_dict(inc: ConfigInclude, included_name: str | None) -> dict:
|
||||
"""Serialize a ConfigInclude to a dict."""
|
||||
return {
|
||||
"id": str(inc.id),
|
||||
"profile_id": str(inc.profile_id),
|
||||
"included_profile_id": str(inc.included_profile_id),
|
||||
"included_profile_name": included_name,
|
||||
"order_index": inc.order_index,
|
||||
"created_at": inc.created_at.isoformat() if inc.created_at else None,
|
||||
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mount helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def check_duplicate_mount_path(
|
||||
session: AsyncSession,
|
||||
profile_id: uuid.UUID,
|
||||
target_path: str,
|
||||
exclude_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Raise 409 if a mount with the given path already exists."""
|
||||
query = select(ConfigMount).where(
|
||||
ConfigMount.profile_id == profile_id,
|
||||
ConfigMount.target_path == target_path,
|
||||
)
|
||||
if exclude_id:
|
||||
query = query.where(ConfigMount.id != exclude_id)
|
||||
existing = await session.scalar(query)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"mount with path '{target_path}' already exists",
|
||||
)
|
||||
|
||||
|
||||
def mount_to_dict(mount: ConfigMount) -> dict:
|
||||
"""Serialize a ConfigMount to a dict."""
|
||||
return {
|
||||
"id": str(mount.id),
|
||||
"profile_id": str(mount.profile_id),
|
||||
"target_path": mount.target_path,
|
||||
"files": mount.files,
|
||||
"mode": mount.mode,
|
||||
"order_index": mount.order_index,
|
||||
"created_at": mount.created_at.isoformat() if mount.created_at else None,
|
||||
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default profile helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_or_create_user_config(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
) -> UserConfig:
|
||||
"""Get existing user config or create a new one."""
|
||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
||||
user_config = result.scalar_one_or_none()
|
||||
if user_config is None:
|
||||
user_config = UserConfig(user_id=user_id, config={})
|
||||
session.add(user_config)
|
||||
return user_config
|
||||
|
||||
|
||||
async def validate_default_profiles(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
default_profiles: dict[str, str],
|
||||
) -> None:
|
||||
"""Validate that all profile IDs in default_profiles belong to the user."""
|
||||
for tool_type_id, profile_id_str in default_profiles.items():
|
||||
profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str))
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"profile {profile_id_str} not found")
|
||||
if profile.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"profile {profile_id_str} does not belong to user")
|
||||
|
||||
|
||||
async def get_default_profiles(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
) -> dict:
|
||||
"""Get default profiles for a user."""
|
||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
||||
user_config = result.scalar_one_or_none()
|
||||
return {"default_profiles": user_config.default_profiles if user_config else {}}
|
||||
|
||||
|
||||
async def set_default_profiles(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
default_profiles: dict[str, str],
|
||||
) -> dict:
|
||||
"""Set default profiles for a user."""
|
||||
user_config = await get_or_create_user_config(session, user_id)
|
||||
await validate_default_profiles(session, user_id, default_profiles)
|
||||
user_config.config = {**user_config.config, "default_profiles": default_profiles}
|
||||
await session.commit()
|
||||
await session.refresh(user_config)
|
||||
return {"default_profiles": user_config.default_profiles}
|
||||
|
||||
|
||||
async def get_default_profile_for_tool_type(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
tool_type_id: str,
|
||||
) -> dict:
|
||||
"""Get default profile for a specific tool type."""
|
||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
||||
user_config = result.scalar_one_or_none()
|
||||
profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None
|
||||
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Include list helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def list_includes_for_profile(
|
||||
session: AsyncSession,
|
||||
profile_id: uuid.UUID,
|
||||
) -> dict:
|
||||
"""List all includes for a profile."""
|
||||
result = await session.execute(
|
||||
select(ConfigInclude)
|
||||
.where(ConfigInclude.profile_id == profile_id)
|
||||
.order_by(ConfigInclude.order_index)
|
||||
)
|
||||
includes_data = []
|
||||
for inc in result.scalars().all():
|
||||
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
|
||||
includes_data.append(include_to_dict(inc, included_profile.name if included_profile else None))
|
||||
return {"includes": includes_data}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mount list helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def list_mounts_for_profile(
|
||||
session: AsyncSession,
|
||||
profile_id: uuid.UUID,
|
||||
) -> dict:
|
||||
"""List all mounts for a profile."""
|
||||
result = await session.execute(
|
||||
select(ConfigMount)
|
||||
.where(ConfigMount.profile_id == profile_id)
|
||||
.order_by(ConfigMount.order_index)
|
||||
)
|
||||
return {"mounts": [mount_to_dict(m) for m in result.scalars().all()]}
|
||||
@@ -1,484 +0,0 @@
|
||||
"""Docker service for managing tool instances."""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import time
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
|
||||
"""Sort volume strings so parent paths come before child paths.
|
||||
|
||||
Docker Compose mounts volumes in array order. A later mount at a parent
|
||||
path hides earlier mounts at child paths. By sorting shallow paths first
|
||||
and deep paths last, deeper (more specific) mounts overlay correctly.
|
||||
|
||||
Volume format: source:target or source:target:type
|
||||
|
||||
Args:
|
||||
volumes: List of Docker volume mount strings.
|
||||
|
||||
Returns:
|
||||
Sorted list with parent paths before child paths.
|
||||
"""
|
||||
|
||||
def _target_depth(vol: str) -> int:
|
||||
parts = vol.split(":")
|
||||
if len(parts) < 2:
|
||||
return 0
|
||||
target = parts[1].rstrip("/")
|
||||
if not target or target == "/":
|
||||
return 0
|
||||
return target.count("/")
|
||||
|
||||
# Detect duplicate targets and warn
|
||||
targets = []
|
||||
for vol in volumes:
|
||||
parts = vol.split(":")
|
||||
targets.append(parts[1] if len(parts) > 1 else "")
|
||||
dupes = [t for t, c in Counter(targets).items() if c > 1]
|
||||
if dupes:
|
||||
logger.warning("Duplicate mount targets detected: %s", dupes)
|
||||
|
||||
# Stable sort: parent paths first, child paths last
|
||||
return sorted(volumes, key=_target_depth)
|
||||
|
||||
|
||||
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
||||
"""Render a Docker Compose template with variable substitution.
|
||||
|
||||
Args:
|
||||
template: The compose template string
|
||||
variables: Dictionary of variable names to values
|
||||
|
||||
Returns:
|
||||
Rendered compose file content
|
||||
"""
|
||||
result = template
|
||||
for key, value in variables.items():
|
||||
placeholder = f"{{{{{key}}}}}"
|
||||
result = result.replace(placeholder, str(value))
|
||||
return result
|
||||
|
||||
|
||||
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
|
||||
"""Create and return the instance directory path.
|
||||
|
||||
Args:
|
||||
instance_id: Unique instance identifier
|
||||
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
|
||||
|
||||
Returns:
|
||||
Absolute path to instance directory
|
||||
"""
|
||||
if base_path is None:
|
||||
from src.config import Settings
|
||||
|
||||
base_path = Settings().instance_base_path
|
||||
instance_dir = Path(base_path) / instance_id
|
||||
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||
return str(instance_dir.absolute())
|
||||
|
||||
|
||||
def write_compose_file(instance_dir: str, content: str) -> str:
|
||||
"""Write the rendered compose file to the instance directory.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
content: Rendered compose content
|
||||
|
||||
Returns:
|
||||
Path to the compose file
|
||||
"""
|
||||
compose_path = Path(instance_dir) / "docker-compose.yml"
|
||||
compose_path.write_text(content)
|
||||
return str(compose_path)
|
||||
|
||||
|
||||
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
|
||||
"""Write environment variables to a .env file.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
env_vars: Dictionary of env var names to values
|
||||
|
||||
Returns:
|
||||
Path to the env file
|
||||
"""
|
||||
env_path = Path(instance_dir) / ".env"
|
||||
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
|
||||
env_path.write_text("\n".join(lines) + "\n")
|
||||
return str(env_path)
|
||||
|
||||
|
||||
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||
"""Write config files to the instance directory.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
files: Dictionary of file paths (relative to instance dir) to content
|
||||
"""
|
||||
instance_path = Path(instance_dir)
|
||||
for file_path, content in files.items():
|
||||
# Ensure the path is within the instance directory (security)
|
||||
full_path = instance_path / file_path
|
||||
try:
|
||||
full_path.resolve().relative_to(instance_path.resolve())
|
||||
except ValueError:
|
||||
raise ValueError(f"File path '{file_path}' escapes instance directory")
|
||||
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
|
||||
|
||||
def execute_compose_command(
|
||||
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||
) -> tuple[int, str, str]:
|
||||
"""Execute a docker compose command.
|
||||
|
||||
Args:
|
||||
compose_path: Path to docker-compose.yml
|
||||
action: The compose action (up, down, start, stop, restart)
|
||||
timeout: Command timeout in seconds
|
||||
env_file: Optional path to .env file for environment variables
|
||||
|
||||
Returns:
|
||||
Tuple of (returncode, stdout, stderr)
|
||||
"""
|
||||
instance_dir = Path(compose_path).parent
|
||||
|
||||
cmd = ["docker", "compose", "-f", compose_path]
|
||||
|
||||
if env_file:
|
||||
cmd.extend(["--env-file", env_file])
|
||||
|
||||
if action == "up":
|
||||
cmd.extend(["up", "-d", "--force-recreate"])
|
||||
elif action == "down":
|
||||
cmd.extend(["down", "-v"])
|
||||
elif action in ("start", "stop", "restart"):
|
||||
cmd.append(action)
|
||||
else:
|
||||
raise ValueError(f"Unknown compose action: {action}")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(instance_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def get_container_id(instance_name: str) -> str | None:
|
||||
"""Get the container ID for a compose service.
|
||||
|
||||
Uses exact name matching to avoid substring collisions with tunnel
|
||||
containers (e.g. tunnel-code-server-... matching code-server-...).
|
||||
Falls back to case-insensitive matching since Docker DNS is case-
|
||||
insensitive but docker inspect is case-sensitive.
|
||||
|
||||
Args:
|
||||
instance_name: The expected container name.
|
||||
|
||||
Returns:
|
||||
Container ID or None if not found.
|
||||
"""
|
||||
expected = instance_name.lower()
|
||||
|
||||
# Fast path: exact match via docker inspect
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.Id}}", expected],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
|
||||
# Fallback: list all containers and do case-insensitive exact match
|
||||
ps_result = subprocess.run(
|
||||
["docker", "ps", "-a", "--format", "{{.Names}}\t{{.ID}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if ps_result.returncode == 0:
|
||||
for line in ps_result.stdout.strip().splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) == 2:
|
||||
name, cid = parts
|
||||
if name.lower() == expected:
|
||||
return cid
|
||||
return None
|
||||
|
||||
|
||||
def get_container_name(instance_name: str) -> str | None:
|
||||
"""Get the full container name for a compose service.
|
||||
|
||||
Uses exact name matching via docker inspect to avoid substring collisions.
|
||||
|
||||
Args:
|
||||
instance_name: The exact container name (case-insensitive for Docker).
|
||||
|
||||
Returns:
|
||||
Container name or None if not found.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.Name}}", instance_name.lower()],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip().lstrip("/")
|
||||
return None
|
||||
|
||||
|
||||
def get_backend_network_name() -> str:
|
||||
"""Auto-detect the actual Docker network name for the backend network.
|
||||
|
||||
Docker Compose prefixes network names with the project directory name
|
||||
(e.g. 'headquarter_backend' instead of 'backend'). We inspect the API
|
||||
container itself to find the real network name it's connected to.
|
||||
|
||||
Returns:
|
||||
The actual Docker network name, or 'backend' as fallback.
|
||||
"""
|
||||
# Try to find the API container by its known name
|
||||
api_container = "hq-api"
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
"{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}",
|
||||
api_container,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
networks = result.stdout.strip().split()
|
||||
for net in networks:
|
||||
if "backend" in net.lower():
|
||||
return net
|
||||
# API container is on some network — return the first one
|
||||
return networks[0]
|
||||
return "backend"
|
||||
|
||||
|
||||
def connect_container_to_network(
|
||||
container_name: str, network_name: str | None = None
|
||||
) -> bool:
|
||||
"""Connect a Docker container to an existing network.
|
||||
|
||||
Args:
|
||||
container_name: Name or ID of the container
|
||||
network_name: Name of the Docker network. If None, auto-detects
|
||||
from the API container's own network membership.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if network_name is None:
|
||||
network_name = get_backend_network_name()
|
||||
result = subprocess.run(
|
||||
["docker", "network", "connect", network_name, container_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def get_container_ip_on_network(
|
||||
container_id: str, network_name: str | None = None
|
||||
) -> str | None:
|
||||
"""Get a container's IP address on a specific Docker network.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID or name.
|
||||
network_name: Network name. If None, auto-detects from the API container.
|
||||
|
||||
Returns:
|
||||
IP address string, or None if the container is not on that network.
|
||||
"""
|
||||
if network_name is None:
|
||||
network_name = get_backend_network_name()
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
f"{{{{.NetworkSettings.Networks.{network_name}.IPAddress}}}}",
|
||||
container_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
ip = result.stdout.strip()
|
||||
if ip and ip != "<no value>":
|
||||
return ip
|
||||
return None
|
||||
|
||||
|
||||
def is_container_on_network(container_id: str, network_name: str | None = None) -> bool:
|
||||
"""Check whether a container is already attached to a Docker network.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID or name.
|
||||
network_name: Network name. If None, auto-detects from the API container.
|
||||
|
||||
Returns:
|
||||
True if the container is on the network.
|
||||
"""
|
||||
if network_name is None:
|
||||
network_name = get_backend_network_name()
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
f"{{{{.NetworkSettings.Networks.{network_name}}}}}",
|
||||
container_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0 and "<no value>" not in result.stdout
|
||||
|
||||
|
||||
def get_container_status(container_id: str) -> dict[str, Any]:
|
||||
"""Get the status of a Docker container.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
|
||||
Returns:
|
||||
Dict with 'status' (running, exited, restarting, not_found),
|
||||
'exit_code' (int or None), and 'health' (health status or None)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
||||
container_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return {"status": "not_found", "exit_code": None, "health": None}
|
||||
|
||||
parts = result.stdout.strip().split("|")
|
||||
status = parts[0] if parts else "unknown"
|
||||
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
||||
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
|
||||
|
||||
return {"status": status, "exit_code": exit_code, "health": health}
|
||||
|
||||
|
||||
def wait_for_container_running(
|
||||
container_id: str, timeout: int = 30, interval: float = 2.0
|
||||
) -> dict[str, Any]:
|
||||
"""Wait for a container to reach the running state.
|
||||
|
||||
Polls docker inspect until the container status is "running" or timeout.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
timeout: Maximum seconds to wait
|
||||
interval: Seconds between polls
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
||||
and 'waited_seconds' (float)
|
||||
"""
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
info = get_container_status(container_id)
|
||||
|
||||
if info["status"] == "running":
|
||||
return {
|
||||
"success": True,
|
||||
"status": "running",
|
||||
"exit_code": None,
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
if info["status"] == "exited":
|
||||
return {
|
||||
"success": False,
|
||||
"status": "exited",
|
||||
"exit_code": info["exit_code"],
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
if info["status"] == "not_found":
|
||||
return {
|
||||
"success": False,
|
||||
"status": "not_found",
|
||||
"exit_code": None,
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
# Timeout reached
|
||||
info = get_container_status(container_id)
|
||||
return {
|
||||
"success": False,
|
||||
"status": info["status"],
|
||||
"exit_code": info["exit_code"],
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
|
||||
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
||||
"""Get the logs of a Docker container.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
tail: Number of lines to return
|
||||
|
||||
Returns:
|
||||
Container logs
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "logs", "--tail", str(tail), container_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
return f"Failed to get logs: {result.stderr}"
|
||||
|
||||
|
||||
def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
||||
"""Find a free TCP port in the given range.
|
||||
|
||||
Args:
|
||||
start: Start of port range
|
||||
end: End of port range
|
||||
|
||||
Returns:
|
||||
Free port number
|
||||
"""
|
||||
import socket
|
||||
|
||||
for port in range(start, end):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
if s.connect_ex(("localhost", port)) != 0:
|
||||
return port
|
||||
|
||||
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Docker services for container and tunnel management."""
|
||||
|
||||
from .compose import (
|
||||
ensure_instance_directory,
|
||||
execute_compose_command,
|
||||
render_compose_template,
|
||||
write_compose_file,
|
||||
write_env_file,
|
||||
)
|
||||
from .config_staging import write_config_files, write_config_folder_files
|
||||
from .container import (
|
||||
connect_container_to_network,
|
||||
find_free_port,
|
||||
get_container_id,
|
||||
get_container_logs,
|
||||
get_container_name,
|
||||
get_container_status,
|
||||
)
|
||||
from .tunnel import (
|
||||
check_tunnel_health,
|
||||
recreate_tunnel,
|
||||
start_cloudflared_tunnel,
|
||||
stop_cloudflared_tunnel,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"render_compose_template",
|
||||
"ensure_instance_directory",
|
||||
"write_compose_file",
|
||||
"write_env_file",
|
||||
"execute_compose_command",
|
||||
"write_config_files",
|
||||
"write_config_folder_files",
|
||||
"get_container_id",
|
||||
"get_container_name",
|
||||
"connect_container_to_network",
|
||||
"get_container_status",
|
||||
"get_container_logs",
|
||||
"find_free_port",
|
||||
"start_cloudflared_tunnel",
|
||||
"stop_cloudflared_tunnel",
|
||||
"recreate_tunnel",
|
||||
"check_tunnel_health",
|
||||
]
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Docker Compose file generation and command execution."""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.config_profile import ConfigProfile
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.services.profile_resolver import resolve_profile
|
||||
|
||||
|
||||
def _sanitize_name(name: str) -> str:
|
||||
"""Sanitize a string for use in Docker/container names."""
|
||||
sanitized = re.sub(r"[^a-z0-9-]", "-", name.lower())
|
||||
sanitized = re.sub(r"-+", "-", sanitized)
|
||||
return sanitized.strip("-")
|
||||
|
||||
|
||||
async def _generate_instance_name(
|
||||
session: AsyncSession,
|
||||
project_name: str,
|
||||
tool_type_name: str,
|
||||
) -> str:
|
||||
"""Generate a unique instance name: project-tool-NUM."""
|
||||
base = f"{_sanitize_name(project_name)}-{_sanitize_name(tool_type_name)}"
|
||||
base = base.strip("-") or "instance"
|
||||
result = await session.execute(
|
||||
select(ToolInstance.name).where(ToolInstance.name.like(f"{base}-%"))
|
||||
)
|
||||
names = result.scalars().all()
|
||||
max_num = 0
|
||||
for name in names:
|
||||
parts = name.rsplit("-", 1)
|
||||
if len(parts) == 2 and parts[0] == base and parts[1].isdigit():
|
||||
max_num = max(max_num, int(parts[1]))
|
||||
return f"{base}-{max_num + 1:03d}"
|
||||
|
||||
|
||||
def _modify_compose_file(
|
||||
compose_path: str,
|
||||
port_override: int | None = None,
|
||||
start_command: str | None = None,
|
||||
working_directory: str | None = None,
|
||||
extra_volumes: list[dict] | None = None,
|
||||
) -> None:
|
||||
"""Modify compose file with runtime overrides."""
|
||||
import yaml
|
||||
|
||||
compose_file = Path(compose_path)
|
||||
content = compose_file.read_text()
|
||||
compose_data = yaml.safe_load(content)
|
||||
|
||||
if not compose_data or "services" not in compose_data:
|
||||
return
|
||||
|
||||
for service_name, service_config in compose_data["services"].items():
|
||||
if port_override and "ports" in service_config:
|
||||
for i, port_mapping in enumerate(service_config["ports"]):
|
||||
if isinstance(port_mapping, str) and ":" in port_mapping:
|
||||
_host_port, container_port = port_mapping.split(":", 1)
|
||||
service_config["ports"][i] = f"{port_override}:{container_port}"
|
||||
break
|
||||
|
||||
if start_command:
|
||||
service_config["command"] = start_command
|
||||
|
||||
if working_directory:
|
||||
service_config["working_dir"] = working_directory
|
||||
|
||||
if extra_volumes:
|
||||
if "volumes" not in service_config:
|
||||
service_config["volumes"] = []
|
||||
for vol in extra_volumes:
|
||||
source = vol.get("source", "")
|
||||
target = vol.get("target", "")
|
||||
vol_type = vol.get("type", "bind")
|
||||
if vol_type == "bind":
|
||||
service_config["volumes"].append(f"{source}:{target}")
|
||||
else:
|
||||
service_config["volumes"].append(f"{source}:{target}:{vol_type}")
|
||||
|
||||
break
|
||||
|
||||
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
||||
|
||||
|
||||
async def _apply_resolved_profile(
|
||||
profile: ConfigProfile,
|
||||
instance_dir: str,
|
||||
env_vars: dict[str, str],
|
||||
port_override: int | None,
|
||||
start_command: str | None,
|
||||
working_directory: str | None,
|
||||
extra_volumes: list[dict],
|
||||
) -> tuple[dict[str, str], int | None, str | None, str | None, list[dict]]:
|
||||
"""Resolve a profile and apply its output to instance configuration."""
|
||||
resolved = resolve_profile(profile)
|
||||
|
||||
if resolved.environment_variables:
|
||||
env_vars.update(resolved.environment_variables)
|
||||
|
||||
if resolved.runtime_hints.start_command is not None:
|
||||
start_command = resolved.runtime_hints.start_command
|
||||
if resolved.runtime_hints.working_directory is not None:
|
||||
working_directory = resolved.runtime_hints.working_directory
|
||||
if resolved.runtime_hints.port is not None:
|
||||
port_override = resolved.runtime_hints.port
|
||||
|
||||
for target_path, mount in resolved.mounts.items():
|
||||
safe_name = target_path.strip("/").replace("/", "_")
|
||||
mount_dir = Path(instance_dir) / "mounts" / safe_name
|
||||
mount_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for rel_path, content in mount.files.items():
|
||||
file_path = mount_dir / rel_path
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content)
|
||||
|
||||
extra_volumes.append({
|
||||
"source": str(mount_dir),
|
||||
"target": target_path,
|
||||
"type": mount.mode,
|
||||
})
|
||||
|
||||
return env_vars, port_override, start_command, working_directory, extra_volumes
|
||||
|
||||
|
||||
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
||||
"""Render a Docker Compose template with variable substitution.
|
||||
|
||||
Args:
|
||||
template: The compose template string
|
||||
variables: Dictionary of variable names to values
|
||||
|
||||
Returns:
|
||||
Rendered compose file content
|
||||
"""
|
||||
result = template
|
||||
for key, value in variables.items():
|
||||
placeholder = f"{{{{{key}}}}}"
|
||||
result = result.replace(placeholder, str(value))
|
||||
return result
|
||||
|
||||
|
||||
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
|
||||
"""Create and return the instance directory path.
|
||||
|
||||
Args:
|
||||
instance_id: Unique instance identifier
|
||||
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
|
||||
|
||||
Returns:
|
||||
Absolute path to instance directory
|
||||
"""
|
||||
if base_path is None:
|
||||
from src.config import Settings
|
||||
base_path = Settings().instance_base_path
|
||||
instance_dir = Path(base_path) / instance_id
|
||||
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||
return str(instance_dir.absolute())
|
||||
|
||||
|
||||
def write_compose_file(instance_dir: str, content: str) -> str:
|
||||
"""Write the rendered compose file to the instance directory.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
content: Rendered compose content
|
||||
|
||||
Returns:
|
||||
Path to the compose file
|
||||
"""
|
||||
compose_path = Path(instance_dir) / "docker-compose.yml"
|
||||
compose_path.write_text(content)
|
||||
return str(compose_path)
|
||||
|
||||
|
||||
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
|
||||
"""Write environment variables to a .env file.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
env_vars: Dictionary of env var names to values
|
||||
|
||||
Returns:
|
||||
Path to the env file
|
||||
"""
|
||||
env_path = Path(instance_dir) / ".env"
|
||||
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
|
||||
env_path.write_text("\n".join(lines) + "\n")
|
||||
return str(env_path)
|
||||
|
||||
|
||||
def execute_compose_command(
|
||||
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||
) -> tuple[int, str, str]:
|
||||
"""Execute a docker compose command.
|
||||
|
||||
Args:
|
||||
compose_path: Path to docker-compose.yml
|
||||
action: The compose action (up, down, start, stop, restart)
|
||||
timeout: Command timeout in seconds
|
||||
env_file: Optional path to .env file for environment variables
|
||||
|
||||
Returns:
|
||||
Tuple of (returncode, stdout, stderr)
|
||||
"""
|
||||
instance_dir = Path(compose_path).parent
|
||||
|
||||
cmd = ["docker", "compose", "-f", compose_path]
|
||||
|
||||
if env_file:
|
||||
cmd.extend(["--env-file", env_file])
|
||||
|
||||
if action == "up":
|
||||
cmd.extend(["up", "-d"])
|
||||
elif action == "down":
|
||||
cmd.extend(["down", "-v"])
|
||||
elif action in ("start", "stop", "restart"):
|
||||
cmd.append(action)
|
||||
else:
|
||||
raise ValueError(f"Unknown compose action: {action}")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(instance_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Config folder file staging for Docker instances."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||
"""Write config files to the instance directory.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
files: Dictionary of file paths (relative to instance dir) to content
|
||||
"""
|
||||
instance_path = Path(instance_dir)
|
||||
for file_path, content in files.items():
|
||||
# Ensure the path is within the instance directory (security)
|
||||
full_path = instance_path / file_path
|
||||
try:
|
||||
full_path.resolve().relative_to(instance_path.resolve())
|
||||
except ValueError:
|
||||
raise ValueError(f"File path '{file_path}' escapes instance directory")
|
||||
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
|
||||
|
||||
def write_config_folder_files(instance_dir: str, folders: list, project_id: str | None = None) -> list[dict]:
|
||||
"""Write config folder files to the instance directory and return volume mounts.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
folders: List of ConfigFolder objects
|
||||
project_id: Optional project ID for applying overrides
|
||||
|
||||
Returns:
|
||||
List of volume mount dicts [{"source": "...", "target": "...", "type": "..."}]
|
||||
"""
|
||||
instance_path = Path(instance_dir)
|
||||
volume_mounts = []
|
||||
|
||||
for folder in folders:
|
||||
# Determine mount path (with project override if applicable)
|
||||
mount_path = folder.mount_path
|
||||
files = folder.files.copy()
|
||||
|
||||
if project_id and folder.project_overrides:
|
||||
override = folder.project_overrides.get(str(project_id))
|
||||
if override:
|
||||
if override.get("mount_path"):
|
||||
mount_path = override["mount_path"]
|
||||
if override.get("files"):
|
||||
files.update(override["files"])
|
||||
|
||||
# Write files to instance directory
|
||||
folder_dir = instance_path / "volumes" / folder.name
|
||||
folder_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for file_path, content in files.items():
|
||||
# Security: ensure path doesn't escape folder_dir
|
||||
full_path = folder_dir / file_path
|
||||
try:
|
||||
full_path.resolve().relative_to(folder_dir.resolve())
|
||||
except ValueError:
|
||||
logger.warning("Config folder file path escapes directory: %s", file_path)
|
||||
continue
|
||||
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
|
||||
# Add volume mount
|
||||
volume_mounts.append({
|
||||
"source": str(folder_dir),
|
||||
"target": mount_path,
|
||||
"type": "bind",
|
||||
})
|
||||
|
||||
return volume_mounts
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Docker container lifecycle and query operations."""
|
||||
|
||||
import socket
|
||||
import subprocess
|
||||
|
||||
|
||||
def get_container_id(instance_name: str) -> str | None:
|
||||
"""Get the container ID for a compose service.
|
||||
|
||||
Args:
|
||||
instance_name: The service name in compose
|
||||
|
||||
Returns:
|
||||
Container ID or None if not found
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip().split("\n")[0]
|
||||
return None
|
||||
|
||||
|
||||
def get_container_name(instance_name: str) -> str | None:
|
||||
"""Get the full container name for a compose service.
|
||||
|
||||
Args:
|
||||
instance_name: The service name in compose
|
||||
|
||||
Returns:
|
||||
Container name or None if not found
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip().split("\n")[0]
|
||||
return None
|
||||
|
||||
|
||||
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
|
||||
"""Connect a Docker container to an existing network.
|
||||
|
||||
Args:
|
||||
container_name: Name or ID of the container
|
||||
network_name: Name of the Docker network (default: backend)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "network", "connect", network_name, container_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def get_container_status(container_id: str) -> str:
|
||||
"""Get the status of a Docker container.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
|
||||
Returns:
|
||||
Container status string (running, exited, etc.)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.State.Status}}", container_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
||||
"""Get the logs of a Docker container.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
tail: Number of lines to return
|
||||
|
||||
Returns:
|
||||
Container logs
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "logs", "--tail", str(tail), container_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
return f"Failed to get logs: {result.stderr}"
|
||||
|
||||
|
||||
def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
||||
"""Find a free TCP port in the given range.
|
||||
|
||||
Args:
|
||||
start: Start of port range
|
||||
end: End of port range
|
||||
|
||||
Returns:
|
||||
Free port number
|
||||
"""
|
||||
for port in range(start, end):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
if s.connect_ex(("localhost", port)) != 0:
|
||||
return port
|
||||
|
||||
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Cloudflare tunnel management for Docker instances."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def start_cloudflared_tunnel(
|
||||
container_name: str, port: int, timeout: int = 30
|
||||
) -> dict[str, str]:
|
||||
"""Start a temporary Cloudflare tunnel for a container.
|
||||
|
||||
Uses 'cloudflared tunnel --url' to create a temporary tunnel
|
||||
with a random trycloudflare.com URL.
|
||||
|
||||
Args:
|
||||
container_name: Name of the Docker container to tunnel to
|
||||
port: Port number the container listens on
|
||||
timeout: Maximum seconds to wait for tunnel URL
|
||||
|
||||
Returns:
|
||||
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
||||
"""
|
||||
import select as sel
|
||||
|
||||
# First verify the container is accessible
|
||||
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
||||
for attempt in range(10):
|
||||
check = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
f"http://{container_name}:{port}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
|
||||
if check.returncode == 0:
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
|
||||
|
||||
# Run cloudflared in background, capture output
|
||||
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
||||
proc = subprocess.Popen(
|
||||
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Wait for the URL to appear in output
|
||||
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
||||
start_time = time.time()
|
||||
url = None
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
# Read available output
|
||||
readable, _, _ = sel.select([proc.stdout], [], [], 1.0)
|
||||
if readable:
|
||||
line = proc.stdout.readline()
|
||||
if line:
|
||||
match = url_pattern.search(line)
|
||||
if match:
|
||||
url = match.group(0)
|
||||
break
|
||||
|
||||
if not url:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
raise RuntimeError(
|
||||
f"Failed to get tunnel URL within {timeout}s. "
|
||||
f"cloudflared output may contain errors."
|
||||
)
|
||||
|
||||
return {"url": url, "pid": str(proc.pid)}
|
||||
|
||||
|
||||
def stop_cloudflared_tunnel(pid: str) -> None:
|
||||
"""Stop a cloudflared tunnel process.
|
||||
|
||||
Args:
|
||||
pid: Process ID of the cloudflared tunnel
|
||||
"""
|
||||
try:
|
||||
os.kill(int(pid), signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass # Already stopped
|
||||
|
||||
|
||||
def recreate_tunnel(
|
||||
container_name: str, port: int, old_pid: str | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Recreate a temporary Cloudflare tunnel.
|
||||
|
||||
Stops the old tunnel (if pid provided) and starts a new one.
|
||||
|
||||
Args:
|
||||
container_name: Name of the Docker container to tunnel to
|
||||
port: Port number the container listens on
|
||||
old_pid: Optional PID of the old tunnel process to stop
|
||||
|
||||
Returns:
|
||||
Dict with 'url' and 'pid' for the new tunnel
|
||||
"""
|
||||
if old_pid:
|
||||
stop_cloudflared_tunnel(old_pid)
|
||||
|
||||
return start_cloudflared_tunnel(container_name, port)
|
||||
|
||||
|
||||
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||
"""Check if a tunnel URL is healthy.
|
||||
|
||||
Args:
|
||||
url: The tunnel URL to check
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
Dict with 'healthy' (bool) and 'status_code' (int or None)
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"--max-time", str(timeout), url],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout + 5,
|
||||
)
|
||||
status_code = int(result.stdout.strip())
|
||||
return {
|
||||
"healthy": 200 <= status_code < 400,
|
||||
"status_code": status_code,
|
||||
}
|
||||
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
|
||||
return {
|
||||
"healthy": False,
|
||||
"status_code": None,
|
||||
"error": str(e),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Git services package."""
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Git control operations with repo validation."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.user import User
|
||||
from src.schemas.git_repository import (
|
||||
BranchCreateRequest,
|
||||
CheckoutRequest,
|
||||
CommitRequest,
|
||||
FetchResponse,
|
||||
MergeRequest,
|
||||
MergeResponse,
|
||||
PullResponse,
|
||||
PushResponse,
|
||||
StatusResponse,
|
||||
)
|
||||
from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate
|
||||
from src.utils.git_control import (
|
||||
checkout_branch,
|
||||
commit_changes,
|
||||
create_branch,
|
||||
delete_branch,
|
||||
fetch,
|
||||
get_status,
|
||||
merge,
|
||||
pull,
|
||||
push,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_status_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
) -> StatusResponse:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
result = get_status(repo.path)
|
||||
return StatusResponse(
|
||||
branch=result.branch,
|
||||
modified=result.modified,
|
||||
added=result.added,
|
||||
deleted=result.deleted,
|
||||
untracked=result.untracked,
|
||||
renamed=result.renamed,
|
||||
ahead=result.ahead,
|
||||
behind=result.behind,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def create_branch_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
data: BranchCreateRequest,
|
||||
) -> dict:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
create_branch(repo.path, data.name, data.base_branch)
|
||||
return {"message": f"Branch '{data.name}' created", "branch": data.name}
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def delete_branch_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
branch_name: str,
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
delete_branch(repo.path, branch_name, force)
|
||||
return {"message": f"Branch '{branch_name}' deleted"}
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def checkout_branch_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
data: CheckoutRequest,
|
||||
) -> dict:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
checkout_branch(repo.path, data.branch)
|
||||
return {"message": f"Checked out branch '{data.branch}'", "branch": data.branch}
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def commit_changes_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
data: CommitRequest,
|
||||
user: User,
|
||||
) -> dict:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
author_name = user.name or "Unknown"
|
||||
author_email = user.email or "unknown@example.com"
|
||||
try:
|
||||
commit_hash = commit_changes(
|
||||
repo_path=repo.path,
|
||||
message=data.message,
|
||||
author_name=author_name,
|
||||
author_email=author_email,
|
||||
files=data.files,
|
||||
)
|
||||
return {"commit_hash": commit_hash, "message": data.message}
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def fetch_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
) -> FetchResponse:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
fetch(repo.path)
|
||||
return FetchResponse(message="Fetched from remote")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def pull_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
branch: str | None = None,
|
||||
) -> PullResponse:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
pull(repo.path, branch)
|
||||
return PullResponse(message="Pulled from remote")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def push_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
branch: str | None = None,
|
||||
) -> PushResponse:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
push(repo.path, branch)
|
||||
return PushResponse(message="Pushed to remote")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def merge_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
data: MergeRequest,
|
||||
) -> MergeResponse:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
commit_hash = merge(
|
||||
repo_path=repo.path,
|
||||
source_branch=data.source_branch,
|
||||
target_branch=data.target_branch,
|
||||
message=data.message,
|
||||
)
|
||||
return MergeResponse(
|
||||
commit_hash=commit_hash,
|
||||
message=data.message or f"Merge {data.source_branch}",
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Git file operations with repo validation."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.user import User
|
||||
from src.schemas.git_repository import (
|
||||
FileContentResponse,
|
||||
FileListResponse,
|
||||
FileUpdateRequest,
|
||||
FileUpdateResponse,
|
||||
)
|
||||
from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate
|
||||
from src.utils.git_files import (
|
||||
commit_file,
|
||||
get_file_content,
|
||||
list_branches,
|
||||
list_tree,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def list_files(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
branch: str = "main",
|
||||
path: str = "",
|
||||
) -> FileListResponse:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
entries = list_tree(repo.path, branch=branch, path=path)
|
||||
return FileListResponse(
|
||||
path=path,
|
||||
branch=branch,
|
||||
entries=[
|
||||
{
|
||||
"name": e.name,
|
||||
"type": e.type,
|
||||
"path": e.path,
|
||||
"size": e.size,
|
||||
"mode": e.mode,
|
||||
"last_commit": e.last_commit,
|
||||
}
|
||||
for e in entries
|
||||
],
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.error(
|
||||
"Failed to list files for repo %s (path=%s, branch=%s): %s",
|
||||
repo_id,
|
||||
path,
|
||||
branch,
|
||||
str(e),
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def get_file(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
branch: str,
|
||||
path: str,
|
||||
) -> FileContentResponse:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
file_content = get_file_content(repo.path, branch=branch, path=path)
|
||||
return FileContentResponse(
|
||||
path=file_content.path,
|
||||
branch=file_content.branch,
|
||||
content=file_content.content,
|
||||
size=file_content.size,
|
||||
encoding=file_content.encoding,
|
||||
language=file_content.language,
|
||||
is_binary=file_content.is_binary,
|
||||
last_commit=file_content.last_commit,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def update_file(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
data: FileUpdateRequest,
|
||||
user: User,
|
||||
) -> FileUpdateResponse:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
author_name = user.name or "Unknown"
|
||||
author_email = user.email or "unknown@example.com"
|
||||
try:
|
||||
commit_hash = commit_file(
|
||||
repo_path=repo.path,
|
||||
branch=data.branch,
|
||||
path=data.path,
|
||||
content=data.content,
|
||||
commit_message=data.commit_message,
|
||||
author_name=author_name,
|
||||
author_email=author_email,
|
||||
)
|
||||
return FileUpdateResponse(
|
||||
commit_hash=commit_hash,
|
||||
message=data.commit_message,
|
||||
branch=data.branch,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def list_branches_with_validation(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
) -> dict:
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
ensure_repo_on_disk(repo)
|
||||
try:
|
||||
branches, default_branch = list_branches(repo.path)
|
||||
return {
|
||||
"branches": [
|
||||
{
|
||||
"name": b.name,
|
||||
"is_default": b.is_default,
|
||||
"last_commit": b.last_commit,
|
||||
}
|
||||
for b in branches
|
||||
],
|
||||
"default_branch": default_branch,
|
||||
}
|
||||
except RuntimeError as e:
|
||||
logger.error(
|
||||
"Failed to list branches for repo %s: %s",
|
||||
repo_id,
|
||||
str(e),
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Repository lifecycle and path helpers."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.config import Settings
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.user import User
|
||||
from src.schemas.git_repository import GitRepositoryCreate
|
||||
from src.utils.git_url_parser import parse_git_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
|
||||
"""Generate the filesystem path for a repository."""
|
||||
base = Settings().repo_base_path or "/data/repos"
|
||||
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
|
||||
|
||||
|
||||
def _build_provider_clone_url(owner: str, repo: str) -> str:
|
||||
"""Build the SSH clone URL for the fixed git provider."""
|
||||
return f"git@git.commumedia.org:{owner}/{repo}.git"
|
||||
|
||||
|
||||
def _preflight_remote_repository(remote_url: str) -> None:
|
||||
"""Verify a remote repository is reachable before cloning."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "ls-remote", remote_url],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="repository not found or inaccessible",
|
||||
)
|
||||
|
||||
|
||||
def _clone_working_repository(remote_url: str, repo_path: str) -> None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "clone", remote_url, repo_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"failed to clone repository: {result.stderr}",
|
||||
)
|
||||
|
||||
|
||||
def _init_working_repository(repo_path: str) -> None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "init", "-b", "main", repo_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||
|
||||
if result.returncode == 0:
|
||||
return
|
||||
|
||||
fallback = subprocess.run(
|
||||
["git", "init", repo_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if fallback.returncode != 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"failed to initialize repository: {fallback.stderr}",
|
||||
)
|
||||
|
||||
ref_result = subprocess.run(
|
||||
["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if ref_result.returncode != 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"failed to set initial branch: {ref_result.stderr}",
|
||||
)
|
||||
|
||||
|
||||
async def get_repo_and_validate(
|
||||
session: AsyncSession,
|
||||
repo_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
) -> GitRepository:
|
||||
"""Fetch a repository and validate ownership + disk presence."""
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
return repo
|
||||
|
||||
|
||||
def ensure_repo_on_disk(repo: GitRepository) -> None:
|
||||
"""Raise 404 if the repository is not present on disk."""
|
||||
if not os.path.exists(repo.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||
|
||||
|
||||
async def create_repository(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
data: GitRepositoryCreate,
|
||||
user: User,
|
||||
) -> GitRepository:
|
||||
"""Create a new git repository (clone or init)."""
|
||||
# Check for duplicate name
|
||||
existing = await session.execute(
|
||||
select(GitRepository).where(
|
||||
GitRepository.project_id == project_id,
|
||||
GitRepository.name == data.name,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
||||
|
||||
# Validate and potentially correct the URL
|
||||
remote_url = data.remote_url
|
||||
if remote_url and not data.force_original_url:
|
||||
parse_result = parse_git_url(remote_url)
|
||||
if parse_result["needs_parsing"] and parse_result["base_url"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"message": "The provided URL appears to be a browser URL, not a git clone URL",
|
||||
"suggested_url": parse_result["base_url"],
|
||||
"original_url": remote_url,
|
||||
"error_code": "URL_NEEDS_PARSING",
|
||||
},
|
||||
)
|
||||
if parse_result["base_url"]:
|
||||
remote_url = parse_result["base_url"]
|
||||
|
||||
if remote_url:
|
||||
_preflight_remote_repository(remote_url)
|
||||
|
||||
repo_path = _get_repo_path(user.id, project_id, data.name)
|
||||
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
||||
|
||||
if remote_url:
|
||||
_clone_working_repository(remote_url, repo_path)
|
||||
else:
|
||||
_init_working_repository(repo_path)
|
||||
|
||||
repo = GitRepository(
|
||||
name=data.name,
|
||||
path=repo_path,
|
||||
project_id=project_id,
|
||||
owner_id=user.id,
|
||||
is_mirror=False,
|
||||
remote_url=remote_url,
|
||||
)
|
||||
session.add(repo)
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
return repo
|
||||
|
||||
|
||||
async def delete_repository(
|
||||
session: AsyncSession,
|
||||
repo_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Delete a repository from DB and disk."""
|
||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||
|
||||
if os.path.exists(repo.path):
|
||||
shutil.rmtree(repo.path)
|
||||
|
||||
await session.delete(repo)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def list_repositories(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
) -> list[GitRepository]:
|
||||
"""List all repositories in a project."""
|
||||
result = await session.execute(
|
||||
select(GitRepository).where(GitRepository.project_id == project_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,420 @@
|
||||
"""High-level tool instance lifecycle orchestration.
|
||||
|
||||
Coordinates Docker compose, container, tunnel, and config staging services
|
||||
to create, start, stop, restart, and delete tool instances.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.config_folder import ConfigFolder
|
||||
from src.models.config_profile import ConfigProfile
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.tool_config import ToolConfig
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.services.docker import compose as compose_svc
|
||||
from src.services.docker import config_staging
|
||||
from src.services.docker import container as container_svc
|
||||
from src.services.docker import tunnel as tunnel_svc
|
||||
from src.services.docker_build import build_image
|
||||
from src.services.readiness_probe import execute_probe
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_new_instance(
|
||||
session: AsyncSession,
|
||||
project: Project,
|
||||
repo: GitRepository,
|
||||
tool_type: ToolType,
|
||||
user: User,
|
||||
display_name: str | None,
|
||||
selected_profile: ConfigProfile | None,
|
||||
) -> ToolInstance:
|
||||
"""Create a new tool instance record and its compose file."""
|
||||
instance_name = await compose_svc._generate_instance_name(
|
||||
session, project.name, tool_type.name
|
||||
)
|
||||
instance_dir = compose_svc.ensure_instance_directory(instance_name)
|
||||
tool_port = container_svc.find_free_port()
|
||||
|
||||
compose_path = await _build_or_render_compose(
|
||||
tool_type, instance_name, instance_dir, repo, user, project.id, tool_port
|
||||
)
|
||||
|
||||
instance = ToolInstance(
|
||||
name=instance_name,
|
||||
display_name=display_name or f"{project.name} / {repo.name} / {tool_type.display_name}",
|
||||
tool_type_id=tool_type.id,
|
||||
repository_id=repo.id,
|
||||
project_id=project.id,
|
||||
owner_id=user.id,
|
||||
status="pending",
|
||||
compose_path=compose_path,
|
||||
port=tool_port,
|
||||
selected_profile_id=selected_profile.id if selected_profile else None,
|
||||
)
|
||||
session.add(instance)
|
||||
await session.commit()
|
||||
await session.refresh(instance)
|
||||
return instance
|
||||
|
||||
|
||||
async def start_existing_instance(
|
||||
session: AsyncSession,
|
||||
instance: ToolInstance,
|
||||
user: User,
|
||||
project_id: Any,
|
||||
) -> dict:
|
||||
"""Start an existing instance: stage configs, compose up, probe, tunnel."""
|
||||
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found"
|
||||
)
|
||||
|
||||
instance.status = "building"
|
||||
await session.commit()
|
||||
|
||||
env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs(
|
||||
session, user.id, instance.tool_type_id, project_id
|
||||
)
|
||||
|
||||
selected_profile = None
|
||||
if instance.selected_profile_id:
|
||||
selected_profile = await session.get(ConfigProfile, instance.selected_profile_id)
|
||||
if selected_profile and selected_profile.user_id == user.id:
|
||||
instance_dir = os.path.dirname(instance.compose_path)
|
||||
env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile(
|
||||
selected_profile,
|
||||
instance_dir,
|
||||
env_vars,
|
||||
port_override,
|
||||
start_command,
|
||||
working_directory,
|
||||
extra_volumes,
|
||||
)
|
||||
|
||||
env_file_path, extra_volumes = await _stage_configs_and_folders(
|
||||
session, user.id, project_id, os.path.dirname(instance.compose_path),
|
||||
env_vars, config_files, extra_volumes
|
||||
)
|
||||
|
||||
if port_override or start_command or working_directory or extra_volumes:
|
||||
compose_svc._modify_compose_file(
|
||||
instance.compose_path, port_override, start_command, working_directory, extra_volumes
|
||||
)
|
||||
|
||||
returncode, _stdout, stderr = compose_svc.execute_compose_command(
|
||||
instance.compose_path, "up", env_file=env_file_path
|
||||
)
|
||||
if returncode != 0:
|
||||
instance.status = "error"
|
||||
await session.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"failed to start instance: {stderr}",
|
||||
)
|
||||
|
||||
container_id = container_svc.get_container_id(instance.name)
|
||||
if container_id:
|
||||
instance.container_id = container_id
|
||||
container_name = container_svc.get_container_name(instance.name)
|
||||
if container_name:
|
||||
instance.container_name = container_name
|
||||
container_svc.connect_container_to_network(container_name, "backend")
|
||||
|
||||
instance.status = "starting"
|
||||
instance.last_started_at = datetime.now()
|
||||
await session.commit()
|
||||
|
||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||
success, probe_logs = await _run_readiness_probe(instance, tool_type)
|
||||
if not success:
|
||||
instance.status = "failed"
|
||||
instance.url = None
|
||||
instance.public_url = None
|
||||
await session.commit()
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": f"Readiness probe failed: {' '.join(probe_logs)}",
|
||||
}
|
||||
|
||||
instance.status = "running"
|
||||
await session.commit()
|
||||
await _start_tunnel_if_web(instance, tool_type)
|
||||
await session.commit()
|
||||
|
||||
return {"status": instance.status, "url": instance.url}
|
||||
|
||||
|
||||
async def restart_existing_instance(
|
||||
session: AsyncSession,
|
||||
instance: ToolInstance,
|
||||
user: User,
|
||||
project_id: Any,
|
||||
) -> dict:
|
||||
"""Restart an instance: re-stage configs, compose restart, tunnel."""
|
||||
if instance.tunnel_id:
|
||||
try:
|
||||
tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to stop old tunnel: %s", exc)
|
||||
|
||||
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
||||
instance.status = "error"
|
||||
await session.commit()
|
||||
return {"status": instance.status}
|
||||
|
||||
env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs(
|
||||
session, user.id, instance.tool_type_id, project_id
|
||||
)
|
||||
|
||||
stored_profile = None
|
||||
if instance.selected_profile_id:
|
||||
stored_profile = await session.get(ConfigProfile, instance.selected_profile_id)
|
||||
if stored_profile and stored_profile.user_id == user.id:
|
||||
instance_dir = os.path.dirname(instance.compose_path)
|
||||
env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile(
|
||||
stored_profile, instance_dir, env_vars, port_override, start_command, working_directory, extra_volumes
|
||||
)
|
||||
|
||||
env_file_path, extra_volumes = await _stage_configs_and_folders(
|
||||
session, user.id, project_id, os.path.dirname(instance.compose_path),
|
||||
env_vars, config_files, extra_volumes
|
||||
)
|
||||
|
||||
if port_override or start_command or working_directory or extra_volumes:
|
||||
compose_svc._modify_compose_file(
|
||||
instance.compose_path, port_override, start_command, working_directory, extra_volumes
|
||||
)
|
||||
|
||||
returncode, _stdout, _stderr = compose_svc.execute_compose_command(
|
||||
instance.compose_path, "restart", env_file=env_file_path
|
||||
)
|
||||
if returncode != 0:
|
||||
instance.status = "error"
|
||||
await session.commit()
|
||||
return {"status": instance.status}
|
||||
|
||||
instance.status = "running"
|
||||
instance.last_started_at = datetime.now()
|
||||
|
||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||
await _start_tunnel_if_web(instance, tool_type)
|
||||
await session.commit()
|
||||
|
||||
return {"status": instance.status, "url": instance.url}
|
||||
|
||||
|
||||
async def stop_existing_instance(session: AsyncSession, instance: ToolInstance) -> None:
|
||||
"""Stop an instance and its tunnel."""
|
||||
if instance.tunnel_id:
|
||||
try:
|
||||
tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to stop tunnel: %s", exc)
|
||||
|
||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||
compose_svc.execute_compose_command(instance.compose_path, "stop")
|
||||
|
||||
instance.status = "stopped"
|
||||
instance.last_stopped_at = datetime.now()
|
||||
instance.url = None
|
||||
instance.public_url = None
|
||||
instance.tunnel_id = None
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def delete_existing_instance(session: AsyncSession, instance: ToolInstance) -> None:
|
||||
"""Delete an instance, its containers, and its directory."""
|
||||
if instance.tunnel_id:
|
||||
try:
|
||||
tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to stop tunnel: %s", exc)
|
||||
|
||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||
compose_svc.execute_compose_command(instance.compose_path, "down")
|
||||
instance_dir = os.path.dirname(instance.compose_path)
|
||||
if os.path.exists(instance_dir):
|
||||
shutil.rmtree(instance_dir)
|
||||
|
||||
await session.delete(instance)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# ── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
async def _build_or_render_compose(
|
||||
tool_type: ToolType,
|
||||
instance_name: str,
|
||||
instance_dir: str,
|
||||
repo: GitRepository,
|
||||
user: User,
|
||||
project_id: Any,
|
||||
tool_port: int,
|
||||
) -> str:
|
||||
"""Build Dockerfile or render compose template."""
|
||||
if tool_type.definition_type == "dockerfile":
|
||||
image_tag = f"headquarter/{instance_name}:latest"
|
||||
if tool_type.dockerfile_template:
|
||||
returncode, _stdout, stderr = build_image(
|
||||
instance_dir=instance_dir,
|
||||
dockerfile=tool_type.dockerfile_template,
|
||||
tag=image_tag,
|
||||
build_context=tool_type.build_context,
|
||||
)
|
||||
if returncode != 0:
|
||||
logger.error("Build failed for %s: %s", instance_name, stderr)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to build Docker image: {stderr[:500]}",
|
||||
)
|
||||
|
||||
compose_content = (
|
||||
f'version: "3.8"\nservices:\n app:\n'
|
||||
f' image: {image_tag}\n'
|
||||
f' container_name: {instance_name}\n'
|
||||
f' ports:\n - "{tool_port}:{tool_type.default_port}"\n'
|
||||
f' volumes:\n - {repo.path}:/workspace\n'
|
||||
f' restart: unless-stopped\n'
|
||||
)
|
||||
else:
|
||||
variables = {
|
||||
"REPO_PATH": repo.path,
|
||||
"INSTANCE_NAME": instance_name,
|
||||
"INSTANCE_ID": instance_name,
|
||||
"TOOL_NAME": instance_name,
|
||||
"TOOL_PORT": tool_port,
|
||||
"USER_ID": str(user.id),
|
||||
"PROJECT_ID": str(project_id),
|
||||
}
|
||||
compose_content = compose_svc.render_compose_template(
|
||||
tool_type.compose_template, variables
|
||||
)
|
||||
|
||||
compose_svc.write_compose_file(instance_dir, compose_content)
|
||||
return os.path.join(instance_dir, "docker-compose.yml")
|
||||
|
||||
|
||||
async def _fetch_tool_configs(
|
||||
session: AsyncSession,
|
||||
user_id: Any,
|
||||
tool_type_id: Any,
|
||||
project_id: Any,
|
||||
) -> tuple[dict, dict, Any, Any, Any, dict, list]:
|
||||
"""Fetch tool configs and return parsed values."""
|
||||
env_vars: dict[str, str] = {}
|
||||
config_files: dict[str, str] = {}
|
||||
port_override = None
|
||||
start_command = None
|
||||
working_directory = None
|
||||
extra_env_vars: dict[str, str] = {}
|
||||
extra_volumes: list[dict] = []
|
||||
|
||||
query = (
|
||||
select(ToolConfig)
|
||||
.where(ToolConfig.user_id == user_id, ToolConfig.tool_type_id == tool_type_id)
|
||||
.where((ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None)))
|
||||
)
|
||||
configs = (await session.execute(query)).scalars().all()
|
||||
|
||||
for cfg in configs:
|
||||
if cfg.config_type == "env":
|
||||
env_vars[cfg.key] = cfg.value
|
||||
elif cfg.config_type == "file" and cfg.file_path:
|
||||
config_files[cfg.file_path] = cfg.value
|
||||
if cfg.port_override:
|
||||
port_override = cfg.port_override
|
||||
if cfg.start_command:
|
||||
start_command = cfg.start_command
|
||||
if cfg.working_directory:
|
||||
working_directory = cfg.working_directory
|
||||
if cfg.environment_variables:
|
||||
extra_env_vars.update(cfg.environment_variables)
|
||||
if cfg.volumes:
|
||||
extra_volumes.extend(cfg.volumes)
|
||||
|
||||
env_vars.update(extra_env_vars)
|
||||
return env_vars, config_files, port_override, start_command, working_directory, extra_env_vars, extra_volumes
|
||||
|
||||
|
||||
async def _stage_configs_and_folders(
|
||||
session: AsyncSession,
|
||||
user_id: Any,
|
||||
project_id: Any,
|
||||
instance_dir: str,
|
||||
env_vars: dict[str, str],
|
||||
config_files: dict[str, str],
|
||||
extra_volumes: list[dict],
|
||||
) -> tuple[str | None, list[dict]]:
|
||||
"""Write env/config files and config folders."""
|
||||
env_file_path: str | None = None
|
||||
if env_vars:
|
||||
env_file_path = compose_svc.write_env_file(instance_dir, env_vars)
|
||||
if config_files:
|
||||
config_staging.write_config_files(instance_dir, config_files)
|
||||
|
||||
folder_query = select(ConfigFolder).where(
|
||||
ConfigFolder.user_id == user_id, ConfigFolder.is_active.is_(True)
|
||||
)
|
||||
folders = (await session.execute(folder_query)).scalars().all()
|
||||
if folders:
|
||||
folder_volumes = config_staging.write_config_folder_files(
|
||||
instance_dir, folders, str(project_id)
|
||||
)
|
||||
extra_volumes.extend(folder_volumes)
|
||||
|
||||
return env_file_path, extra_volumes
|
||||
|
||||
|
||||
async def _start_tunnel_if_web(instance: ToolInstance, tool_type: ToolType) -> None:
|
||||
"""Create Cloudflare tunnel for web-enabled tools."""
|
||||
if "web" not in tool_type.interfaces or not tool_type.default_port:
|
||||
instance.url = None
|
||||
instance.public_url = None
|
||||
return
|
||||
|
||||
try:
|
||||
tunnel_info = tunnel_svc.start_cloudflared_tunnel(
|
||||
container_name=instance.container_name or instance.name,
|
||||
port=tool_type.default_port,
|
||||
)
|
||||
instance.tunnel_id = tunnel_info["pid"]
|
||||
instance.public_url = tunnel_info["url"]
|
||||
instance.url = tunnel_info["url"]
|
||||
logger.info("Created tunnel for instance %s: %s", instance.id, tunnel_info["url"])
|
||||
except Exception as exc:
|
||||
logger.error("Failed to create tunnel for instance %s: %s", instance.id, exc)
|
||||
instance.status = "error"
|
||||
instance.url = None
|
||||
|
||||
|
||||
async def _run_readiness_probe(
|
||||
instance: ToolInstance, tool_type: ToolType
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Run readiness probe if configured."""
|
||||
if not tool_type.readiness_probe or not instance.container_id:
|
||||
return True, []
|
||||
|
||||
probe = tool_type.readiness_probe
|
||||
command = probe.get("command", "")
|
||||
if not command:
|
||||
return True, []
|
||||
|
||||
return await execute_probe(
|
||||
container_id=instance.container_id,
|
||||
command=command,
|
||||
timeout=probe.get("timeout", 30),
|
||||
interval=probe.get("interval", 2),
|
||||
)
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Profile resolver service for recursive ordered include resolution.
|
||||
|
||||
Provides deterministic merge rules, save-independent cycle protection,
|
||||
and resolved output structures for env vars, runtime hints, mounts,
|
||||
file trees, and override metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from src.models.config_include import ConfigInclude
|
||||
from src.models.config_mount import ConfigMount
|
||||
from src.models.config_profile import ConfigProfile
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedMount:
|
||||
"""A resolved mount with merged file tree and final mode."""
|
||||
|
||||
target_path: str
|
||||
mode: str # "ro" or "rw"
|
||||
files: dict[str, str] = field(default_factory=dict)
|
||||
"""Relative file paths to UTF-8 text content."""
|
||||
overridden_files: dict[str, list[str]] = field(default_factory=dict)
|
||||
"""Map of relative file path to list of profile names that contributed
|
||||
(latest is the winner)."""
|
||||
mode_overridden_by: str | None = None
|
||||
"""Name of the profile that set the final mode, if different from first."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedRuntimeHints:
|
||||
"""Resolved runtime hints from profile layers."""
|
||||
|
||||
start_command: str | None = None
|
||||
working_directory: str | None = None
|
||||
port: int | None = None
|
||||
overridden_hints: dict[str, str] = field(default_factory=dict)
|
||||
"""Map of hint key to profile name that provided the winning value."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedProfileOutput:
|
||||
"""Complete resolved output for a config profile."""
|
||||
|
||||
profile_id: uuid.UUID
|
||||
profile_name: str
|
||||
environment_variables: dict[str, str] = field(default_factory=dict)
|
||||
"""Final merged env vars (later layers win)."""
|
||||
env_var_sources: dict[str, list[str]] = field(default_factory=dict)
|
||||
"""Map of env var key to ordered list of contributing profile names
|
||||
(latest is the winner)."""
|
||||
runtime_hints: ResolvedRuntimeHints = field(
|
||||
default_factory=lambda: ResolvedRuntimeHints()
|
||||
)
|
||||
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
|
||||
"""Map of target_path to ResolvedMount."""
|
||||
resolution_order: list[str] = field(default_factory=list)
|
||||
"""Ordered list of profile names as they were resolved."""
|
||||
cycle_detected: bool = False
|
||||
cycle_path: list[str] | None = None
|
||||
|
||||
|
||||
class ProfileResolutionError(Exception):
|
||||
"""Raised when profile resolution fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ProfileCycleError(ProfileResolutionError):
|
||||
"""Raised when a cycle is detected during profile resolution."""
|
||||
|
||||
def __init__(self, cycle_path: list[str]) -> None:
|
||||
self.cycle_path = cycle_path
|
||||
path_str = " -> ".join(cycle_path)
|
||||
super().__init__(f"Profile include cycle detected: {path_str}")
|
||||
|
||||
|
||||
def _merge_env_vars(
|
||||
current: dict[str, str],
|
||||
sources: dict[str, list[str]],
|
||||
profile: ConfigProfile,
|
||||
) -> None:
|
||||
"""Merge a profile's env vars into the current dict, tracking sources."""
|
||||
if not profile.environment_variables:
|
||||
return
|
||||
for key, value in profile.environment_variables.items():
|
||||
current[key] = value
|
||||
if key not in sources:
|
||||
sources[key] = []
|
||||
sources[key].append(profile.name)
|
||||
|
||||
|
||||
def _merge_runtime_hints(
|
||||
hints: ResolvedRuntimeHints,
|
||||
profile: ConfigProfile,
|
||||
) -> None:
|
||||
"""Merge a profile's runtime hints, tracking overrides."""
|
||||
if profile.start_command is not None:
|
||||
hints.start_command = profile.start_command
|
||||
hints.overridden_hints["start_command"] = profile.name
|
||||
if profile.working_directory is not None:
|
||||
hints.working_directory = profile.working_directory
|
||||
hints.overridden_hints["working_directory"] = profile.name
|
||||
if profile.port is not None:
|
||||
hints.port = profile.port
|
||||
hints.overridden_hints["port"] = profile.name
|
||||
|
||||
|
||||
def _merge_mounts(
|
||||
mounts: dict[str, ResolvedMount],
|
||||
profile_mounts: list[ConfigMount],
|
||||
profile: ConfigProfile,
|
||||
) -> None:
|
||||
"""Merge a profile's mounts into the current mounts dict."""
|
||||
for mount in profile_mounts:
|
||||
target = mount.target_path
|
||||
if target not in mounts:
|
||||
mounts[target] = ResolvedMount(
|
||||
target_path=target,
|
||||
mode=mount.mode,
|
||||
files={},
|
||||
overridden_files={},
|
||||
)
|
||||
resolved = mounts[target]
|
||||
|
||||
# Mode override: later wins
|
||||
if resolved.mode != mount.mode:
|
||||
resolved.mode = mount.mode
|
||||
resolved.mode_overridden_by = profile.name
|
||||
|
||||
# File tree merge: later wins for same relative path
|
||||
if mount.files:
|
||||
for rel_path, content in mount.files.items():
|
||||
if rel_path not in resolved.files:
|
||||
resolved.overridden_files[rel_path] = []
|
||||
else:
|
||||
if rel_path not in resolved.overridden_files:
|
||||
resolved.overridden_files[rel_path] = []
|
||||
resolved.overridden_files[rel_path].append(profile.name)
|
||||
resolved.files[rel_path] = content
|
||||
|
||||
|
||||
def _resolve_profile_recursive(
|
||||
profile: ConfigProfile,
|
||||
visited: set[uuid.UUID],
|
||||
path: list[str],
|
||||
resolution_order: list[str],
|
||||
env_vars: dict[str, str],
|
||||
env_var_sources: dict[str, list[str]],
|
||||
runtime_hints: ResolvedRuntimeHints,
|
||||
mounts: dict[str, ResolvedMount],
|
||||
) -> None:
|
||||
"""Recursively resolve a profile and its includes.
|
||||
|
||||
Args:
|
||||
profile: The profile to resolve
|
||||
visited: Set of already-resolved profile IDs to avoid duplicates
|
||||
path: Current recursion path for cycle detection
|
||||
resolution_order: Ordered list of profile names being resolved
|
||||
env_vars: Accumulated environment variables
|
||||
env_var_sources: Tracking of which profiles contributed each env var
|
||||
runtime_hints: Accumulated runtime hints
|
||||
mounts: Accumulated mounts
|
||||
|
||||
Raises:
|
||||
ProfileCycleError: If a cycle is detected
|
||||
"""
|
||||
if profile.name in path:
|
||||
# Cycle detected
|
||||
cycle_start = path.index(profile.name)
|
||||
cycle_path = path[cycle_start:] + [profile.name]
|
||||
raise ProfileCycleError(cycle_path)
|
||||
|
||||
if profile.id in visited:
|
||||
# Already resolved in another branch (diamond graph)
|
||||
return
|
||||
|
||||
visited.add(profile.id)
|
||||
path.append(profile.name)
|
||||
resolution_order.append(profile.name)
|
||||
|
||||
# Resolve includes first (in order)
|
||||
includes: list[ConfigInclude] = list(profile.includes)
|
||||
includes.sort(key=lambda inc: inc.order_index)
|
||||
for include in includes:
|
||||
included_profile = include.included_profile
|
||||
if included_profile is not None:
|
||||
_resolve_profile_recursive(
|
||||
included_profile,
|
||||
visited,
|
||||
path,
|
||||
resolution_order,
|
||||
env_vars,
|
||||
env_var_sources,
|
||||
runtime_hints,
|
||||
mounts,
|
||||
)
|
||||
|
||||
# Apply this profile's values (later layers win)
|
||||
_merge_env_vars(env_vars, env_var_sources, profile)
|
||||
_merge_runtime_hints(runtime_hints, profile)
|
||||
_merge_mounts(mounts, list(profile.mounts), profile)
|
||||
|
||||
path.pop()
|
||||
|
||||
|
||||
def resolve_profile(profile: ConfigProfile) -> ResolvedProfileOutput:
|
||||
"""Resolve a config profile with all its includes.
|
||||
|
||||
Processes included profiles in configured order, then applies the
|
||||
selected profile itself. Later layers override earlier layers.
|
||||
|
||||
Args:
|
||||
profile: The root profile to resolve
|
||||
|
||||
Returns:
|
||||
ResolvedProfileOutput with merged env vars, runtime hints, mounts,
|
||||
and override metadata
|
||||
|
||||
Raises:
|
||||
ProfileCycleError: If a cycle is detected in the include graph
|
||||
"""
|
||||
env_vars: dict[str, str] = {}
|
||||
env_var_sources: dict[str, list[str]] = {}
|
||||
runtime_hints = ResolvedRuntimeHints()
|
||||
mounts: dict[str, ResolvedMount] = {}
|
||||
resolution_order: list[str] = []
|
||||
|
||||
_resolve_profile_recursive(
|
||||
profile,
|
||||
set(),
|
||||
[],
|
||||
resolution_order,
|
||||
env_vars,
|
||||
env_var_sources,
|
||||
runtime_hints,
|
||||
mounts,
|
||||
)
|
||||
|
||||
return ResolvedProfileOutput(
|
||||
profile_id=profile.id,
|
||||
profile_name=profile.name,
|
||||
environment_variables=env_vars,
|
||||
env_var_sources=env_var_sources,
|
||||
runtime_hints=runtime_hints,
|
||||
mounts=mounts,
|
||||
resolution_order=resolution_order,
|
||||
)
|
||||
@@ -1,426 +1,193 @@
|
||||
"""Terminal session manager for WebSocket connections."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from collections.abc import Coroutine
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from src.database import SessionLocal
|
||||
from src.models.terminal_session import TerminalSessionModel
|
||||
from src.services.terminal_session import TerminalSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MaxSessionsExceededError(Exception):
|
||||
"""Raised when the maximum number of terminal sessions per instance is reached."""
|
||||
|
||||
def __init__(self, instance_id: str, max_sessions: int = 5) -> None:
|
||||
self.instance_id = instance_id
|
||||
self.max_sessions = max_sessions
|
||||
super().__init__(
|
||||
f"Maximum of {max_sessions} terminal sessions reached for instance {instance_id}"
|
||||
)
|
||||
_READ_BATCH_INTERVAL_S = 0.016 # 16ms max batching delay
|
||||
_READ_POLL_TIMEOUT_S = 0.005
|
||||
_READ_POLL_SLEEP_S = 0.001
|
||||
_HEARTBEAT_INTERVAL_S = 15.0
|
||||
_IDLE_TIMEOUT_S = 60.0
|
||||
|
||||
|
||||
class TerminalManager:
|
||||
"""Manages active terminal sessions with persistence support."""
|
||||
|
||||
# Maximum sessions per tool instance
|
||||
MAX_SESSIONS_PER_INSTANCE = 5
|
||||
"""Manages active terminal sessions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Track sessions by (instance_id, session_id) for multi-session support
|
||||
self._sessions: dict[tuple[str, str], TerminalSession] = {}
|
||||
self._idle_check_task: asyncio.Task | None = None
|
||||
self._start_idle_check()
|
||||
|
||||
def _start_idle_check(self) -> None:
|
||||
"""Start the idle timeout background task."""
|
||||
if self._idle_check_task is not None and not self._idle_check_task.done():
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
self._idle_check_task = loop.create_task(self._idle_check_loop())
|
||||
except RuntimeError:
|
||||
# No event loop running yet, will be started lazily
|
||||
pass
|
||||
|
||||
async def _idle_check_loop(self) -> None:
|
||||
"""Periodically check for idle sessions and clean them up."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(60) # Check every minute
|
||||
await self._cleanup_idle_sessions()
|
||||
except Exception as exc:
|
||||
logger.error("Error in idle check loop: %s", exc)
|
||||
|
||||
async def _cleanup_idle_sessions(self) -> None:
|
||||
"""Clean up sessions that have been idle for too long."""
|
||||
idle_keys = []
|
||||
for (instance_id, session_id), session in list(self._sessions.items()):
|
||||
if session.is_idle():
|
||||
idle_keys.append((instance_id, session_id))
|
||||
|
||||
for key in idle_keys:
|
||||
instance_id, session_id = key
|
||||
logger.info(
|
||||
"Cleaning up idle terminal session %s for instance %s",
|
||||
session_id,
|
||||
instance_id,
|
||||
)
|
||||
session = self._sessions.pop(key, None)
|
||||
if session:
|
||||
await session.close()
|
||||
# Update DB status fire-and-forget
|
||||
asyncio.create_task(self._mark_closed_in_db(session_id))
|
||||
|
||||
async def _insert_db_session_row(
|
||||
self,
|
||||
session_id: str,
|
||||
instance_id: uuid.UUID,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Insert a TerminalSessionModel row into the database.
|
||||
|
||||
Uses ON CONFLICT DO NOTHING to handle races when a session is
|
||||
restored from DB and then re-inserted.
|
||||
"""
|
||||
try:
|
||||
async with SessionLocal() as db_session:
|
||||
stmt = (
|
||||
pg_insert(TerminalSessionModel)
|
||||
.values(
|
||||
id=uuid.UUID(session_id),
|
||||
instance_id=instance_id,
|
||||
name=name,
|
||||
status="active",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_activity_at=datetime.now(timezone.utc),
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=["id"])
|
||||
)
|
||||
await db_session.execute(stmt)
|
||||
await db_session.commit()
|
||||
logger.debug(
|
||||
"Inserted terminal session row %s for instance %s",
|
||||
session_id,
|
||||
instance_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to insert terminal session row: %s", exc)
|
||||
|
||||
async def _mark_closed_in_db(self, session_id: str) -> None:
|
||||
"""Mark a terminal session as closed in the database."""
|
||||
try:
|
||||
async with SessionLocal() as db_session:
|
||||
db_row = await db_session.get(
|
||||
TerminalSessionModel, uuid.UUID(session_id)
|
||||
)
|
||||
if db_row:
|
||||
db_row.status = "closed"
|
||||
db_row.closed_at = datetime.now(timezone.utc)
|
||||
await db_session.commit()
|
||||
logger.debug(
|
||||
"Marked terminal session %s as closed in DB", session_id
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to mark terminal session as closed in DB: %s", exc)
|
||||
|
||||
def _count_sessions_for_instance(self, instance_id_str: str) -> int:
|
||||
"""Count active in-memory sessions for a given instance."""
|
||||
return sum(1 for (iid, _sid) in self._sessions if iid == instance_id_str)
|
||||
"""Initialise the terminal manager."""
|
||||
self._sessions: dict[str, TerminalSession] = {}
|
||||
self._last_client_message: dict[str, float] = {}
|
||||
self._background_tasks: set[asyncio.Task[Any]] = set()
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
instance_id: uuid.UUID,
|
||||
container_id: str,
|
||||
startup_command: str | None = None,
|
||||
name: str | None = None,
|
||||
session_id: str | None = None,
|
||||
websocket: WebSocket,
|
||||
) -> TerminalSession:
|
||||
"""Create a new terminal session for an instance.
|
||||
|
||||
Enforces a maximum of MAX_SESSIONS_PER_INSTANCE sessions per instance.
|
||||
Inserts a DB row fire-and-forget.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
container_id: Docker container ID.
|
||||
startup_command: Optional startup command to run.
|
||||
name: Optional session name (auto-generated if omitted).
|
||||
|
||||
Returns:
|
||||
The newly created TerminalSession.
|
||||
|
||||
Raises:
|
||||
MaxSessionsExceededError: If the instance already has max sessions.
|
||||
"""
|
||||
instance_id_str = str(instance_id)
|
||||
|
||||
if (
|
||||
self._count_sessions_for_instance(instance_id_str)
|
||||
>= self.MAX_SESSIONS_PER_INSTANCE
|
||||
):
|
||||
raise MaxSessionsExceededError(
|
||||
instance_id_str, self.MAX_SESSIONS_PER_INSTANCE
|
||||
)
|
||||
|
||||
if session_id is None:
|
||||
session_id = str(uuid.uuid4())
|
||||
session = TerminalSession(
|
||||
session_id=session_id,
|
||||
instance_id=instance_id,
|
||||
container_id=container_id,
|
||||
startup_command=startup_command,
|
||||
name=name,
|
||||
)
|
||||
await session.start(startup_command=startup_command)
|
||||
|
||||
key = (instance_id_str, session_id)
|
||||
self._sessions[key] = session
|
||||
|
||||
# Fire-and-forget DB insert (skip if row already exists)
|
||||
asyncio.create_task(
|
||||
self._insert_db_session_row(session_id, instance_id, session.name)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Created terminal session %s for instance %s (name=%s)",
|
||||
session_id,
|
||||
instance_id,
|
||||
session.name,
|
||||
)
|
||||
return session
|
||||
|
||||
async def get_or_create_session(
|
||||
self,
|
||||
instance_id: uuid.UUID,
|
||||
container_id: str,
|
||||
startup_command: str | None = None,
|
||||
) -> TerminalSession:
|
||||
"""Get existing session or create a new one.
|
||||
|
||||
Backward-compatible alias that uses 'default' as the session_id.
|
||||
"""
|
||||
# Ensure idle check is running (lazy start)
|
||||
self._start_idle_check()
|
||||
|
||||
instance_id_str = str(instance_id)
|
||||
key = (instance_id_str, "default")
|
||||
|
||||
# Check for existing default session
|
||||
if key in self._sessions:
|
||||
session = self._sessions[key]
|
||||
|
||||
# Check if session is still alive
|
||||
if session.is_alive():
|
||||
logger.debug(
|
||||
"Reattaching to existing terminal session for instance %s",
|
||||
instance_id,
|
||||
)
|
||||
return session
|
||||
else:
|
||||
# Session died, clean it up
|
||||
logger.debug(
|
||||
"Existing session for instance %s is dead, cleaning up",
|
||||
instance_id,
|
||||
)
|
||||
await session.close()
|
||||
del self._sessions[key]
|
||||
|
||||
# Create new default session
|
||||
logger.info(
|
||||
"Creating new default terminal session for instance %s", instance_id
|
||||
)
|
||||
"""Create a new terminal session."""
|
||||
session_id = str(uuid.uuid4())
|
||||
session = TerminalSession(
|
||||
session_id=session_id,
|
||||
instance_id=instance_id,
|
||||
container_id=container_id,
|
||||
startup_command=startup_command,
|
||||
name="Session 1",
|
||||
)
|
||||
await session.start(startup_command=startup_command)
|
||||
self._sessions[key] = session
|
||||
session = TerminalSession(session_id, instance_id, container_id)
|
||||
await session.start()
|
||||
self._sessions[session_id] = session
|
||||
self._last_client_message[session_id] = time.monotonic()
|
||||
|
||||
# Fire-and-forget DB insert
|
||||
asyncio.create_task(
|
||||
self._insert_db_session_row(session_id, instance_id, session.name)
|
||||
)
|
||||
# Start background tasks for I/O streaming
|
||||
self._start_task(self._read_loop(session, websocket))
|
||||
self._start_task(self._write_loop(session, websocket))
|
||||
self._start_task(self._heartbeat_loop(session, websocket))
|
||||
|
||||
return session
|
||||
|
||||
def get_session(
|
||||
self,
|
||||
instance_id: str,
|
||||
session_id: str,
|
||||
) -> TerminalSession | None:
|
||||
"""Lookup a session by composite key, or by internal session_id."""
|
||||
session = self._sessions.get((instance_id, session_id))
|
||||
if session is not None:
|
||||
return session
|
||||
# Fallback: search by internal TerminalSession.session_id
|
||||
for (iid, _sid), sess in self._sessions.items():
|
||||
if iid == instance_id and sess.session_id == session_id:
|
||||
return sess
|
||||
return None
|
||||
def _start_task(self, coro: Coroutine[Any, Any, None]) -> None:
|
||||
"""Start a background task and store a reference to prevent GC."""
|
||||
task = asyncio.create_task(coro)
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
def _find_key_by_internal_id(
|
||||
self,
|
||||
instance_id: str,
|
||||
internal_session_id: str,
|
||||
) -> tuple[str, str] | None:
|
||||
"""Find the manager dict key for a session by its internal session_id."""
|
||||
for (iid, sid), session in self._sessions.items():
|
||||
if iid == instance_id and session.session_id == internal_session_id:
|
||||
return (iid, sid)
|
||||
return None
|
||||
|
||||
def get_sessions_for_instance(
|
||||
self,
|
||||
instance_id: str,
|
||||
) -> list[TerminalSession]:
|
||||
"""Return all in-memory sessions for a given instance."""
|
||||
return [
|
||||
session
|
||||
for (iid, _sid), session in self._sessions.items()
|
||||
if iid == instance_id
|
||||
]
|
||||
|
||||
async def close_session(
|
||||
self,
|
||||
instance_id: str,
|
||||
session_id: str,
|
||||
) -> None:
|
||||
"""Close a specific session and update its DB status."""
|
||||
key = (instance_id, session_id)
|
||||
session = self._sessions.pop(key, None)
|
||||
if session:
|
||||
await session.close()
|
||||
# Fire-and-forget DB update
|
||||
asyncio.create_task(self._mark_closed_in_db(session_id))
|
||||
logger.info(
|
||||
"Closed terminal session %s for instance %s",
|
||||
session_id,
|
||||
instance_id,
|
||||
)
|
||||
|
||||
async def attach_websocket(
|
||||
async def _read_loop(
|
||||
self,
|
||||
session: TerminalSession,
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
"""Attach a WebSocket to an existing session.
|
||||
"""Read output from the container and send to WebSocket with batching."""
|
||||
try:
|
||||
buffer = bytearray()
|
||||
last_flush = time.monotonic()
|
||||
|
||||
Closes existing WebSocket connections only for this specific session.
|
||||
"""
|
||||
# Handle concurrent connections - close existing ones within the same session
|
||||
if session.has_websockets():
|
||||
logger.debug(
|
||||
"Closing existing WebSocket connections for session %s (instance %s)",
|
||||
while session.is_alive() and not session.closed:
|
||||
data = await session.read_output(select_timeout=_READ_POLL_TIMEOUT_S)
|
||||
if data:
|
||||
buffer.extend(data)
|
||||
|
||||
now = time.monotonic()
|
||||
flush_due = buffer and (
|
||||
now - last_flush >= _READ_BATCH_INTERVAL_S or not data
|
||||
)
|
||||
|
||||
if flush_due:
|
||||
await websocket.send_bytes(bytes(buffer))
|
||||
buffer.clear()
|
||||
last_flush = now
|
||||
elif not data:
|
||||
await asyncio.sleep(_READ_POLL_SLEEP_S)
|
||||
|
||||
# Flush any remaining data
|
||||
if buffer:
|
||||
with contextlib.suppress(Exception):
|
||||
await websocket.send_bytes(bytes(buffer))
|
||||
|
||||
except Exception:
|
||||
logger.exception("Read loop error for session %s", session.session_id)
|
||||
finally:
|
||||
await self._cleanup_session(session)
|
||||
|
||||
async def _write_loop(
|
||||
self,
|
||||
session: TerminalSession,
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
"""Read input from WebSocket and send to container."""
|
||||
try:
|
||||
while session.is_alive() and not session.closed:
|
||||
message = await websocket.receive()
|
||||
self._last_client_message[session.session_id] = time.monotonic()
|
||||
|
||||
if message["type"] == "websocket.receive":
|
||||
if "bytes" in message:
|
||||
await session.write_input(message["bytes"])
|
||||
elif "text" in message:
|
||||
text = message["text"]
|
||||
if text.startswith("{"):
|
||||
try:
|
||||
ctrl = json.loads(text)
|
||||
await self._handle_control_message(
|
||||
session,
|
||||
websocket,
|
||||
ctrl,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Invalid JSON control message: %s", text)
|
||||
else:
|
||||
await session.write_input(text.encode("utf-8"))
|
||||
elif message["type"] == "websocket.disconnect":
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("Write loop error for session %s", session.session_id)
|
||||
finally:
|
||||
await self._cleanup_session(session)
|
||||
|
||||
async def _handle_control_message(
|
||||
self,
|
||||
session: TerminalSession,
|
||||
websocket: WebSocket,
|
||||
ctrl: dict[str, Any],
|
||||
) -> None:
|
||||
"""Handle a JSON control message from the client."""
|
||||
msg_type = ctrl.get("type")
|
||||
if msg_type == "resize":
|
||||
await session.resize(
|
||||
ctrl.get("cols", 80),
|
||||
ctrl.get("rows", 24),
|
||||
)
|
||||
elif msg_type == "ping":
|
||||
await websocket.send_json(
|
||||
{"type": "pong", "id": ctrl.get("id")},
|
||||
)
|
||||
|
||||
async def _heartbeat_loop(
|
||||
self,
|
||||
session: TerminalSession,
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
"""Monitor client activity and close idle connections."""
|
||||
try:
|
||||
while session.is_alive() and not session.closed:
|
||||
await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
|
||||
last_msg = self._last_client_message.get(session.session_id, 0)
|
||||
if time.monotonic() - last_msg > _IDLE_TIMEOUT_S:
|
||||
# Client has been silent for 60s — close connection
|
||||
with contextlib.suppress(Exception):
|
||||
await websocket.close(
|
||||
code=1000,
|
||||
reason="Idle timeout",
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Heartbeat loop error for session %s",
|
||||
session.session_id,
|
||||
session.instance_id,
|
||||
)
|
||||
for ws in list(session._websockets):
|
||||
try:
|
||||
await ws.close(code=4000, reason="New connection established")
|
||||
except Exception:
|
||||
pass # noqa: S110
|
||||
session._websockets.clear()
|
||||
finally:
|
||||
await self._cleanup_session(session)
|
||||
|
||||
# Attach new WebSocket
|
||||
session.attach_websocket(websocket)
|
||||
|
||||
# Replay buffer
|
||||
buffer = session.get_buffer()
|
||||
if buffer:
|
||||
try:
|
||||
await websocket.send_bytes(buffer)
|
||||
except Exception:
|
||||
pass # noqa: S110
|
||||
|
||||
async def detach_websocket(
|
||||
self,
|
||||
session: TerminalSession,
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
"""Detach a WebSocket from a session."""
|
||||
session.detach_websocket(websocket)
|
||||
|
||||
async def reset_session(
|
||||
self,
|
||||
instance_id: uuid.UUID,
|
||||
container_id: str,
|
||||
startup_command: str | None = None,
|
||||
session_id: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> TerminalSession:
|
||||
"""Reset a session by killing it and creating a new one.
|
||||
|
||||
Args:
|
||||
instance_id: UUID of the tool instance.
|
||||
container_id: Docker container ID.
|
||||
startup_command: Optional startup command.
|
||||
session_id: Specific session to reset. If None, resets the default session.
|
||||
name: Optional name to preserve for the new session.
|
||||
|
||||
Returns:
|
||||
The newly created TerminalSession.
|
||||
"""
|
||||
instance_id_str = str(instance_id)
|
||||
target_session_id = session_id or "default"
|
||||
key = (instance_id_str, target_session_id)
|
||||
|
||||
# Preserve old name if not provided
|
||||
old_name = name
|
||||
if old_name is None and key in self._sessions:
|
||||
old_name = self._sessions[key].name
|
||||
|
||||
# Close existing session if any
|
||||
if key in self._sessions:
|
||||
logger.debug(
|
||||
"Resetting terminal session %s for instance %s",
|
||||
target_session_id,
|
||||
instance_id,
|
||||
)
|
||||
old_session = self._sessions.pop(key)
|
||||
await old_session.close()
|
||||
# Fire-and-forget DB update for old session
|
||||
asyncio.create_task(self._mark_closed_in_db(old_session.session_id))
|
||||
|
||||
# Create new session preserving the same session_id slot
|
||||
new_session_id = str(uuid.uuid4())
|
||||
new_session = TerminalSession(
|
||||
session_id=new_session_id,
|
||||
instance_id=instance_id,
|
||||
container_id=container_id,
|
||||
startup_command=startup_command,
|
||||
name=old_name or ("Session 1" if target_session_id == "default" else None),
|
||||
)
|
||||
await new_session.start(startup_command=startup_command)
|
||||
self._sessions[key] = new_session
|
||||
|
||||
# Fire-and-forget DB insert
|
||||
asyncio.create_task(
|
||||
self._insert_db_session_row(new_session_id, instance_id, new_session.name)
|
||||
)
|
||||
|
||||
return new_session
|
||||
async def _cleanup_session(self, session: TerminalSession) -> None:
|
||||
"""Clean up a session."""
|
||||
if session.session_id in self._sessions:
|
||||
del self._sessions[session.session_id]
|
||||
self._last_client_message.pop(session.session_id, None)
|
||||
await session.close()
|
||||
|
||||
async def close_all(self) -> None:
|
||||
"""Close all active sessions."""
|
||||
sessions = list(self._sessions.values())
|
||||
self._sessions.clear()
|
||||
self._last_client_message.clear()
|
||||
for session in sessions:
|
||||
await session.close()
|
||||
|
||||
if self._idle_check_task and not self._idle_check_task.done():
|
||||
self._idle_check_task.cancel()
|
||||
|
||||
|
||||
# Global terminal manager instance
|
||||
terminal_manager = TerminalManager()
|
||||
|
||||
@@ -1,136 +1,44 @@
|
||||
"""High-performance terminal session with asyncio-native I/O.
|
||||
|
||||
Replaces blocking select.select() with event-driven asyncio.add_reader()
|
||||
for sub-frame latency. Includes output batching and flow control.
|
||||
"""
|
||||
"""Terminal session management for tool instances."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import pty
|
||||
import signal
|
||||
import select
|
||||
import struct
|
||||
import fcntl
|
||||
import time
|
||||
import termios
|
||||
import uuid
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TerminalSession:
|
||||
"""Manages a single terminal session with event-driven PTY I/O.
|
||||
|
||||
Uses asyncio.add_reader() instead of polling for near-zero read latency.
|
||||
Output is batched (2ms window) and sent as binary WebSocket frames.
|
||||
Flow control prevents memory bloat on fast output.
|
||||
"""
|
||||
|
||||
# Circular buffer for replay (10KB)
|
||||
BUFFER_SIZE = 10 * 1024
|
||||
|
||||
# Idle timeout in seconds (30 minutes)
|
||||
IDLE_TIMEOUT = 30 * 60
|
||||
|
||||
# Output batching window in seconds
|
||||
BATCH_WINDOW_S = 0.002 # 2ms
|
||||
|
||||
# Flow control: pause PTY reads when unacknowledged bytes exceed this
|
||||
FLOW_CONTROL_PAUSE = 64 * 1024
|
||||
|
||||
# Flow control: resume PTY reads when unacknowledged bytes drop below this
|
||||
FLOW_CONTROL_RESUME = 32 * 1024
|
||||
|
||||
# Max WebSocket frame size
|
||||
MAX_FRAME_SIZE = 64 * 1024
|
||||
|
||||
# Session number counter per instance_id for auto-naming
|
||||
_instance_counters: dict[str, int] = {}
|
||||
"""Manages a single terminal session connected to a docker container."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str,
|
||||
instance_id: uuid.UUID,
|
||||
container_id: str,
|
||||
startup_command: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize a terminal session."""
|
||||
self.session_id = session_id
|
||||
self.instance_id = instance_id
|
||||
self.container_id = container_id
|
||||
self.startup_command = startup_command
|
||||
self.process: asyncio.subprocess.Process | None = None
|
||||
self._closed = False
|
||||
self._master_fd: int | None = None
|
||||
self._slave_fd: int | None = None
|
||||
self._echo_enabled = True
|
||||
self._exit_reason: str | None = None
|
||||
|
||||
# Circular buffer for output replay
|
||||
self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE)
|
||||
self._buffer_size = 0
|
||||
|
||||
# WebSocket connections
|
||||
self._websockets: set[Any] = set()
|
||||
|
||||
# Activity tracking
|
||||
self.last_activity = time.time()
|
||||
|
||||
# Terminal size
|
||||
self._cols = 80
|
||||
self._rows = 24
|
||||
|
||||
# Session metadata
|
||||
self.name = name or self._generate_name(str(instance_id))
|
||||
self.status: str = "active"
|
||||
|
||||
# Output batching
|
||||
self._batch_buffer = bytearray()
|
||||
self._batch_timer: asyncio.TimerHandle | None = None
|
||||
self._batch_lock = asyncio.Lock()
|
||||
|
||||
# Flow control
|
||||
self._unacknowledged_bytes = 0
|
||||
self._paused = False
|
||||
self._read_handler_set = False
|
||||
self._flow_control_lock = asyncio.Lock()
|
||||
|
||||
# Ack timeout fallback
|
||||
self._ack_timeout_handle: asyncio.TimerHandle | None = None
|
||||
|
||||
@classmethod
|
||||
def _generate_name(cls, instance_id: str) -> str:
|
||||
"""Generate an auto-incremented session name for the instance."""
|
||||
count = cls._instance_counters.get(instance_id, 0) + 1
|
||||
cls._instance_counters[instance_id] = count
|
||||
return f"Session {count}"
|
||||
|
||||
async def start(self, startup_command: str | None = None) -> None:
|
||||
async def start(self) -> None:
|
||||
"""Start the docker exec process with a shell using a PTY."""
|
||||
# Create a pseudo-terminal on the host
|
||||
self._master_fd, slave_fd = pty.openpty()
|
||||
self._master_fd, self._slave_fd = pty.openpty()
|
||||
self._set_terminal_size(80, 24)
|
||||
|
||||
# Set the terminal size initially
|
||||
self._set_terminal_size(self._cols, self._rows)
|
||||
logger.debug(
|
||||
"Starting terminal session %s for container %s with initial size %sx%s",
|
||||
self.session_id,
|
||||
self.container_id,
|
||||
self._cols,
|
||||
self._rows,
|
||||
)
|
||||
|
||||
# Build the shell command
|
||||
cmd = startup_command or self.startup_command
|
||||
if cmd:
|
||||
shell_cmd = f'bash -c "{cmd}" || true; exec bash -il'
|
||||
logger.debug(
|
||||
"Using startup command for session %s: %s",
|
||||
self.session_id,
|
||||
cmd,
|
||||
)
|
||||
else:
|
||||
shell_cmd = "bash -il"
|
||||
|
||||
# Start docker exec with the slave fd as stdin/stdout/stderr
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"exec",
|
||||
@@ -139,287 +47,112 @@ class TerminalSession:
|
||||
"TERM=xterm-256color",
|
||||
self.container_id,
|
||||
"bash",
|
||||
"-c",
|
||||
shell_cmd,
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
"-il",
|
||||
stdin=self._slave_fd,
|
||||
stdout=self._slave_fd,
|
||||
stderr=self._slave_fd,
|
||||
)
|
||||
|
||||
# Close slave fd in parent process
|
||||
os.close(slave_fd)
|
||||
os.close(self._slave_fd)
|
||||
self._slave_fd = None
|
||||
self._echo_enabled = self._detect_echo_state()
|
||||
|
||||
self.last_activity = time.time()
|
||||
|
||||
# Start event-driven reading
|
||||
self._start_reading()
|
||||
|
||||
def _start_reading(self) -> None:
|
||||
"""Register PTY master fd with asyncio event loop for event-driven reads."""
|
||||
if self._read_handler_set or self._master_fd is None or self._closed:
|
||||
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
||||
"""Set the terminal size using TIOCSWINSZ."""
|
||||
if self._master_fd is None:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.add_reader(self._master_fd, self._on_fd_readable)
|
||||
self._read_handler_set = True
|
||||
logger.debug("Started event-driven reading for session %s", self.session_id)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to start reading for session %s: %s", self.session_id, exc
|
||||
)
|
||||
tiocswinsz = 0x5414
|
||||
size = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
with contextlib.suppress(OSError):
|
||||
fcntl.ioctl(self._master_fd, tiocswinsz, size)
|
||||
|
||||
def _stop_reading(self) -> None:
|
||||
"""Unregister PTY master fd from asyncio event loop."""
|
||||
if not self._read_handler_set or self._master_fd is None:
|
||||
return
|
||||
def _detect_echo_state(self) -> bool:
|
||||
"""Detect whether the PTY has echo enabled via termios."""
|
||||
if self._master_fd is None:
|
||||
return True
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.remove_reader(self._master_fd)
|
||||
self._read_handler_set = False
|
||||
except Exception:
|
||||
pass
|
||||
attrs = termios.tcgetattr(self._master_fd)
|
||||
return bool(attrs[3] & termios.ECHO)
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
def _on_fd_readable(self) -> None:
|
||||
"""Callback when PTY master fd has data available (called by event loop)."""
|
||||
async def check_echo_state(self) -> bool | None:
|
||||
"""Check if echo state changed. Returns new state if changed, None otherwise."""
|
||||
current = self._detect_echo_state()
|
||||
if current != self._echo_enabled:
|
||||
self._echo_enabled = current
|
||||
return current
|
||||
return None
|
||||
|
||||
@property
|
||||
def echo_enabled(self) -> bool:
|
||||
"""Return whether the PTY currently has echo enabled."""
|
||||
return self._echo_enabled
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
"""Return whether the session has been closed."""
|
||||
return self._closed
|
||||
|
||||
async def read_output(self, select_timeout: float = 0.1) -> bytes:
|
||||
"""Read output from the PTY master."""
|
||||
if self._master_fd is None or self._closed:
|
||||
return
|
||||
|
||||
return b""
|
||||
try:
|
||||
data = os.read(self._master_fd, 4096)
|
||||
except (OSError, IOError) as exc:
|
||||
logger.debug("PTY read error for session %s: %s", self.session_id, exc)
|
||||
self._handle_eof()
|
||||
return
|
||||
|
||||
if not data:
|
||||
# EOF: docker exec process exited
|
||||
logger.debug("PTY EOF for session %s", self.session_id)
|
||||
self._handle_eof()
|
||||
return
|
||||
|
||||
self._add_to_buffer(data)
|
||||
self.last_activity = time.time()
|
||||
|
||||
# Queue for batching + flow control
|
||||
self._queue_output(data)
|
||||
|
||||
def _add_to_buffer(self, data: bytes) -> None:
|
||||
"""Add data to circular buffer, maintaining size limit."""
|
||||
self._output_buffer.append(data)
|
||||
self._buffer_size += len(data)
|
||||
while self._buffer_size > self.BUFFER_SIZE and self._output_buffer:
|
||||
removed = self._output_buffer.popleft()
|
||||
self._buffer_size -= len(removed)
|
||||
|
||||
def _queue_output(self, data: bytes) -> None:
|
||||
"""Add output to batch buffer and schedule flush."""
|
||||
self._batch_buffer.extend(data)
|
||||
self._unacknowledged_bytes += len(data)
|
||||
|
||||
# Check flow control
|
||||
if self._unacknowledged_bytes > self.FLOW_CONTROL_PAUSE and not self._paused:
|
||||
self._pause_output()
|
||||
|
||||
# Schedule batch flush if not already scheduled
|
||||
if self._batch_timer is None:
|
||||
loop = asyncio.get_event_loop()
|
||||
self._batch_timer = loop.call_later(
|
||||
self.BATCH_WINDOW_S,
|
||||
self._flush_batch_sync,
|
||||
readable, _, _ = select.select(
|
||||
[self._master_fd],
|
||||
[],
|
||||
[],
|
||||
select_timeout,
|
||||
)
|
||||
|
||||
def _flush_batch_sync(self) -> None:
|
||||
"""Synchronous entry point for batch flush (called from event loop)."""
|
||||
self._batch_timer = None
|
||||
if not self._batch_buffer or not self._websockets:
|
||||
self._batch_buffer.clear()
|
||||
return
|
||||
|
||||
payload = bytes(self._batch_buffer)
|
||||
self._batch_buffer.clear()
|
||||
|
||||
# Send to all websockets (asyncio.create_task for async send)
|
||||
dead_sockets = set()
|
||||
for ws in list(self._websockets):
|
||||
try:
|
||||
asyncio.create_task(self._send_bytes(ws, payload))
|
||||
except Exception:
|
||||
dead_sockets.add(ws)
|
||||
|
||||
if dead_sockets:
|
||||
self._websockets -= dead_sockets
|
||||
|
||||
async def _send_bytes(self, ws: Any, payload: bytes) -> None:
|
||||
"""Send bytes to a single websocket, catching errors."""
|
||||
try:
|
||||
await ws.send_bytes(payload)
|
||||
except Exception:
|
||||
self._websockets.discard(ws)
|
||||
|
||||
def acknowledge_data(self, char_count: int) -> None:
|
||||
"""Client acknowledges processing char_count bytes.
|
||||
|
||||
Called from the WebSocket handler when the client sends an 'ack' message.
|
||||
"""
|
||||
self._unacknowledged_bytes = max(0, self._unacknowledged_bytes - char_count)
|
||||
|
||||
if self._paused and self._unacknowledged_bytes < self.FLOW_CONTROL_RESUME:
|
||||
self._resume_output()
|
||||
|
||||
# Reset ack timeout
|
||||
if self._ack_timeout_handle:
|
||||
self._ack_timeout_handle.cancel()
|
||||
loop = asyncio.get_event_loop()
|
||||
self._ack_timeout_handle = loop.call_later(5.0, self._ack_timeout_fallback)
|
||||
|
||||
def _ack_timeout_fallback(self) -> None:
|
||||
"""If no ack received for 5s, assume client is dead and resume."""
|
||||
logger.warning(
|
||||
"Flow control ack timeout for session %s, resuming output",
|
||||
self.session_id,
|
||||
)
|
||||
self._unacknowledged_bytes = 0
|
||||
if self._paused:
|
||||
self._resume_output()
|
||||
|
||||
def _pause_output(self) -> None:
|
||||
"""Pause reading from PTY due to flow control."""
|
||||
self._paused = True
|
||||
self._stop_reading()
|
||||
logger.debug(
|
||||
"Paused output for session %s (%d unacked)",
|
||||
self.session_id,
|
||||
self._unacknowledged_bytes,
|
||||
)
|
||||
|
||||
def _resume_output(self) -> None:
|
||||
"""Resume reading from PTY."""
|
||||
self._paused = False
|
||||
self._start_reading()
|
||||
logger.debug("Resumed output for session %s", self.session_id)
|
||||
|
||||
def get_buffer(self) -> bytes:
|
||||
"""Get buffered output for replay."""
|
||||
return b"".join(self._output_buffer)
|
||||
|
||||
def _handle_eof(self) -> None:
|
||||
"""Handle PTY EOF: process died, close websockets to force reconnect."""
|
||||
self._stop_reading()
|
||||
# Mark process as done so is_alive() returns False
|
||||
if self.process is not None and self.process.returncode is None:
|
||||
# Force returncode to a non-None value since the process is dead
|
||||
# but asyncio.subprocess may not have set it yet
|
||||
try:
|
||||
self.process._transport.close() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
# Close all websockets to force frontend reconnection
|
||||
dead_sockets = set(self._websockets)
|
||||
self._websockets.clear()
|
||||
for ws in dead_sockets:
|
||||
try:
|
||||
asyncio.create_task(
|
||||
ws.close(code=4001, reason="Session process exited")
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Session %s EOF handled, websockets closed", self.session_id)
|
||||
if readable:
|
||||
return os.read(self._master_fd, 8192)
|
||||
return b""
|
||||
except (OSError, ValueError):
|
||||
return b""
|
||||
|
||||
async def write_input(self, data: bytes) -> None:
|
||||
"""Write input to the PTY master."""
|
||||
if self._master_fd is None or self._closed:
|
||||
return
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.write(self._master_fd, data)
|
||||
self.last_activity = time.time()
|
||||
except (OSError, IOError) as exc:
|
||||
logger.debug("PTY write error for session %s: %s", self.session_id, exc)
|
||||
self._handle_eof()
|
||||
|
||||
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
||||
"""Set the terminal size using TIOCSWINSZ."""
|
||||
if self._master_fd is None:
|
||||
logger.warning("Cannot resize: master_fd is None (session not started)")
|
||||
return
|
||||
TIOCSWINSZ = 0x5414
|
||||
size = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
try:
|
||||
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
||||
logger.debug("Resized PTY to %sx%s (fd=%s)", cols, rows, self._master_fd)
|
||||
except (OSError, IOError) as e:
|
||||
logger.error("Failed to resize PTY: %s", e)
|
||||
|
||||
async def resize(self, cols: int, rows: int) -> None:
|
||||
"""Resize the terminal."""
|
||||
if self._closed:
|
||||
logger.warning("Cannot resize: session is closed")
|
||||
return
|
||||
|
||||
if cols == self._cols and rows == self._rows:
|
||||
return
|
||||
|
||||
self._cols = cols
|
||||
self._rows = rows
|
||||
logger.debug(
|
||||
"resize() called for session %s: %sx%s", self.session_id, cols, rows
|
||||
)
|
||||
self._set_terminal_size(cols, rows)
|
||||
|
||||
# Send SIGWINCH to docker exec process
|
||||
if self.process and self.process.pid:
|
||||
try:
|
||||
os.kill(self.process.pid, signal.SIGWINCH)
|
||||
except ProcessLookupError:
|
||||
logger.warning("docker exec process %s not found", self.process.pid)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to send SIGWINCH: %s", e)
|
||||
|
||||
async def reset(self) -> None:
|
||||
"""Reset the session by killing the process and clearing state."""
|
||||
self.status = "resetting"
|
||||
await self.close()
|
||||
self._closed = False
|
||||
self._output_buffer.clear()
|
||||
self._buffer_size = 0
|
||||
self._websockets.clear()
|
||||
self._batch_buffer.clear()
|
||||
self._batch_timer = None
|
||||
self._unacknowledged_bytes = 0
|
||||
self._paused = False
|
||||
self._read_handler_set = False
|
||||
self.process = None
|
||||
self._master_fd = None
|
||||
self.status = "active"
|
||||
def get_exit_reason(self) -> str | None:
|
||||
"""Return the reason the session ended, if known."""
|
||||
return self._exit_reason
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the session and cleanup."""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
self.status = "closed"
|
||||
|
||||
self._stop_reading()
|
||||
|
||||
if self._batch_timer:
|
||||
self._batch_timer.cancel()
|
||||
self._batch_timer = None
|
||||
|
||||
if self._ack_timeout_handle:
|
||||
self._ack_timeout_handle.cancel()
|
||||
self._ack_timeout_handle = None
|
||||
# Determine exit reason
|
||||
if self.process is not None and self.process.returncode is not None:
|
||||
if self.process.returncode == 0:
|
||||
self._exit_reason = "process_exit"
|
||||
else:
|
||||
self._exit_reason = "process_exit"
|
||||
else:
|
||||
self._exit_reason = "timeout"
|
||||
|
||||
if self._master_fd is not None:
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(self._master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self._master_fd = None
|
||||
|
||||
if self.process is not None:
|
||||
try:
|
||||
self.process.kill()
|
||||
await asyncio.wait_for(self.process.wait(), timeout=2.0)
|
||||
except (asyncio.TimeoutError, ProcessLookupError):
|
||||
except (TimeoutError, ProcessLookupError):
|
||||
pass
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
@@ -427,41 +160,3 @@ class TerminalSession:
|
||||
if self.process is None:
|
||||
return False
|
||||
return self.process.returncode is None
|
||||
|
||||
def is_idle(self) -> bool:
|
||||
"""Check if the session has been idle for too long."""
|
||||
if self._websockets:
|
||||
return False
|
||||
return time.time() - self.last_activity > self.IDLE_TIMEOUT
|
||||
|
||||
def attach_websocket(self, websocket: Any) -> None:
|
||||
"""Attach a WebSocket to this session."""
|
||||
self._websockets.add(websocket)
|
||||
self.last_activity = time.time()
|
||||
|
||||
def detach_websocket(self, websocket: Any) -> None:
|
||||
"""Detach a WebSocket from this session."""
|
||||
self._websockets.discard(websocket)
|
||||
|
||||
def has_websockets(self) -> bool:
|
||||
"""Check if any WebSockets are attached."""
|
||||
return len(self._websockets) > 0
|
||||
|
||||
async def send_to_all(self, data: bytes) -> None:
|
||||
"""Send data to all attached WebSockets (used for control messages)."""
|
||||
dead_sockets = set()
|
||||
for ws in self._websockets:
|
||||
try:
|
||||
await ws.send_bytes(data)
|
||||
except Exception:
|
||||
dead_sockets.add(ws)
|
||||
for ws in dead_sockets:
|
||||
self._websockets.discard(ws)
|
||||
|
||||
async def read_output(self) -> bytes:
|
||||
"""Legacy method: read output synchronously.
|
||||
|
||||
With event-driven I/O, output is automatically sent to websockets.
|
||||
This method returns any buffered data for callers that poll.
|
||||
"""
|
||||
return b""
|
||||
|
||||
Reference in New Issue
Block a user