Compare commits

..

24 Commits

Author SHA1 Message Date
Developer 703cf1f88b fix: mobile terminal back and X buttons navigate to /sessions
Terminal sessions are opened in a new tab via window.open with
noopener,noreferrer. In a new tab, window.history has no previous
entry, so navigate(-1) silently does nothing. Both the back arrow
and the X button in the mobile terminal overlay called navigate(-1),
which made them appear broken.

Change both buttons to navigate('/sessions') so they always exit
to a sensible page regardless of how the terminal was opened.

Quality gates: tsc --noEmit (pass), build (pass)
2026-06-04 18:24:48 +00:00
alex 5dc7d44111 feat: improve session naming with workspace-aware scoped numbering
When a workspace is provided, auto-generated display names now use
workspace.name instead of repo.name:
  'myworkspace / VS Code Server'          # first
  'myworkspace / VS Code Server #2'       # second

Without a workspace, naming falls back to repo.name:
  'myrepo / VS Code Server'
  'myrepo / VS Code Server #2'

The counter is scoped to workspace+tool_type (or repo+tool_type),
so different tool types for the same workspace/repo are numbered
independently.

This replaces the old format of 'project / repo / tool #N' which
was always repo-based and included the project name even though
the sidebar already groups by project.

Quality gates: py_compile passed, ruff passed.
2026-06-04 16:23:40 +02:00
alex ee348643f8 fix: update main.py imports for service subpackages
services/correlation.py was moved to services/shared/correlation.py,
services/event_bus.py to services/instance/event_bus.py, and
services/health_monitor.py to services/instance/health_monitor.py
but main.py was still importing from the old flat paths.

Updated main.py to import from the new subpackage paths via
__init__.py re-exports.

Quality gates: py_compile passed, ruff passed.
2026-06-04 16:12:28 +02:00
alex 05a598812b style: fix import formatting after service reorganization
Minor ruff-driven import wrapping fixes in tool_instances.py and
workspace/__init__.py after service subpackage moves.
2026-06-04 12:49:28 +02:00
alex 7515d9106f docs: update progress.md — all refactoring complete, verification done 2026-06-04 12:47:28 +02:00
alex 8c7affc933 fix: correct relative imports after component reorganization
Fixed import paths for 43 components moved into features/ directories.
Key fixes:
- api/, types/, hooks/, state/, utils/ imports need ../../../ from features/*/
- components/ imports need ../../ from features/*/
- Cross-feature imports use relative paths (e.g., ../tool/tools-bottom-sheet)
- app-shell.tsx updated to import from features/ subdirectories

Frontend typecheck now passes except for one pre-existing error:
xterm-addon-webgl missing type declarations.

Quality gates: ruff passed on backend, py_compile passed on all backend files.
2026-06-04 12:46:45 +02:00
alex 2680a8c44a docs: update progress.md — all refactoring phases complete 2026-06-04 12:38:06 +02:00
alex 1021d61be3 refactor: organize frontend components into features/ directories
Moved 43 component files into 9 feature domains:
- features/git/ — commit-dialog, commit-panel, file-editor, git-mount-editor,
  git-toolbar, merge-dialog
- features/project/ — repositories-settings-tab, repository-create-dialog
- features/terminal/ — special-keys-panel, special-keys-strip,
  terminal-session-tabs, terminal
- features/workspace/ — workspace-card, workspace-create-form,
  workspace-header, workspace-instance-chips
- features/session/ — create-session-form, session-card, session-list
- features/tool/ — instance-list, manifest-editor, start-tool-fab,
  start-tool-modal, tool-starter, tools-bottom-sheet
- features/notification/ — event-toast-bridge, notification-center,
  notification-item
- features/settings/ — settings-tab-layout
- features/mobile/ — mobile-action-sheet, mobile-detail-view, mobile-edit-view,
  mobile-fab, mobile-list-view, mobile-nav, mobile-page-header,
  mobile-terminal-header, mobile-terminal-wrapper

Updated all imports across pages and components.
Root components/ now only contains generic UI pieces:
app-shell, code-editor, data-states, icon, protected-route, syntax-highlighter.

Quality gates: verified no remaining old imports.
2026-06-04 12:37:24 +02:00
alex 7224afafd1 refactor: rename frontend pages to PascalCase with Page suffix
Renamed 18 page files:
- dashboard.tsx → DashboardPage.tsx
- projects.tsx → ProjectsPage.tsx
- sessions.tsx → SessionsPage.tsx
- settings.tsx → SettingsPage.tsx
- ssh-keys.tsx → SshKeysPage.tsx
- terminal.tsx → TerminalPage.tsx
- tool-workshop.tsx → ToolWorkshopPage.tsx
- config-profiles.tsx → ConfigProfilesPage.tsx
- git-repositories.tsx → GitRepositoriesPage.tsx
- repo-workspace.tsx → RepoWorkspacePage.tsx
- workspaces.tsx → WorkspacesPage.tsx
- workspace-detail.tsx → WorkspaceDetailPage.tsx
- profile.tsx → ProfilePage.tsx
- project-settings.tsx → ProjectSettingsPage.tsx
- git-history.tsx → GitHistoryPage.tsx
- placeholder.tsx → PlaceholderPage.tsx

Updated router.tsx imports.

Quality gates: verified no remaining old imports.
2026-06-04 12:34:43 +02:00
alex 020f832eed refactor: rename frontend API files to kebab-case
Renamed:
- git_repositories.ts → git-repositories.ts
- ssh_keys.ts → ssh-keys.ts
- tool_types.ts → tool-types.ts
- tool_types.test.ts → tool-types.test.ts

Updated all imports across components, pages, and hooks.

Quality gates: verified no remaining old imports.
2026-06-04 12:33:10 +02:00
alex 7fe2790199 docs: update progress.md with API router reorganization 2026-06-04 12:30:33 +02:00
alex 38c51ed95e refactor: move API routers into domain subpackages
Moves 21 API router files into 6 domain subpackages (max 6 files each):
- api/tool/ — tool_instances, tool_types, tool_definitions, sessions
- api/config/ — config_profiles, user_config
- api/workspace/ — workspaces, workspace_files, workspace_git, workspace_instances
- api/user/ — users, auth, ssh_keys
- api/project/ — projects, git_repositories
- api/system/ — health, events, notifications, dashboard, terminal, instance_proxy

sessions_router extracted from tool_instances.py into tool/sessions.py.

main.py now imports from subpackage __init__.py re-exports.
Cross-router imports updated to use new paths.
Fixed pre-existing E712 in tool_definitions.py (is_base == False → is_(False)).

Quality gates: py_compile passed on all files, ruff passed.
2026-06-04 12:28:41 +02:00
alex 37ccaa4fdc refactor: organize API routers and services into subpackages
Service organization (19 files moved into 6 subpackages):
- services/instance/ — event_bus, health_monitor, lifecycle_hooks
- services/config/ — config_profile_resolver
- services/git/ — clone, git_operations, git_service
- services/build/ — docker_build, manifest_compiler
- services/terminal/ — terminal_manager, terminal_session
- services/shared/ — correlation, file_service, notification_service,
  permission_fixer, readiness_probe, ssh_keys, tunnel, workspace_manager

API router organization (16 files moved into 6 subpackages):
- api/tool/ — tool_instances, tool_types, tool_definitions,
  tool_types_validation, sessions (extracted from tool_instances)
- api/config/ — config_profiles, user_config
- api/workspace/ — workspaces, workspace_files, workspace_git,
  workspace_instances
- api/user/ — users, auth, ssh_keys
- api/project/ — projects, git_repositories
- api/system/ — health, events, notifications, dashboard, terminal,
  instance_proxy

Updated main.py imports and all __init__.py re-exports.
Sessions router extracted from tool_instances.py into api/tool/sessions.py.

Quality gates: py_compile passed, ruff passed.
2026-06-04 12:24:14 +02:00
alex 8816ee02ce refactor: extract Pydantic schemas into schemas/ subpackages
Extract inline Pydantic models from 9 API routers into dedicated
schema modules under schemas/:

- schemas/tool/tool_type.py — ToolTypeCreate, ToolTypeUpdate, etc.
- schemas/tool/tool_instance.py — CreateInstanceRequest, StartInstanceRequest
- schemas/config/config_profile.py — ConfigProfileCreate, ConfigProfileUpdate,
  ConfigProfileResponse, DefaultProfilesUpdate, ValidateGitUrlRequest, etc.
- schemas/system/health.py — DatabaseHealth, DiskHealth, HealthResponse, etc.
- schemas/user/user.py — UserProfileResponse, UserProfileUpdate
- schemas/user/user_config.py — UserConfigResponse, UserConfigUpdate
- schemas/project/project.py — ProjectCreate, ProjectUpdate, etc.
- schemas/project/ssh_key.py — SSHKeyCreate, SSHKeyResponse, etc.
- schemas/project/git_repository.py — GitRepositoryCreate, etc.

API routers now import from src.schemas.* instead of defining inline.
Net change: -548 lines across 16 files.

Quality gates: py_compile passed, ruff passed on all 18 files.
2026-06-04 12:15:26 +02:00
alex 6104f592eb refactor: split services/docker.py into docker/ package
Split monolithic docker.py into focused modules:
- docker/compose.py — compose generation, execute_compose_command, volume sorting
- docker/container.py — container status, IP, logs, network, port finding
- docker/config_staging.py — instance dir, env file, config file staging
- docker/tunnel.py — cloudflared tunnel lifecycle (moved from services/tunnel.py)
- docker/__init__.py — re-exports all public symbols for backward compatibility
- services/tunnel.py — thin re-export wrapper for backward compatibility

Also includes schema extraction files created in prior work:
- schemas/config/config_profile.py
- schemas/project/*.py
- schemas/system/health.py
- schemas/tool/*.py
- schemas/user/*.py

All existing imports like 'from src.services.docker import X' and
'from src.services.tunnel import X' continue to work unchanged.

Quality gates: py_compile passed, ruff passed, import test passed.
2026-06-04 10:12:41 +02:00
alex 0591b00ded refactor: reorganize models into subpackages
Move models into domain subpackages (max 4 files each):
- models/tool/ — tool_type, tool_instance, tool_definition_manifest
- models/config/ — config_profile
- models/user/ — user, user_config, ssh_key
- models/project/ — project, git_repository, workspace
- models/system/ — health_check, notification, instance_event, terminal_session

models/__init__.py continues to re-export all symbols, so consumers
using 'from src.models import X' are unaffected. Updated direct file
imports across the backend to use the new paths.

Quality gates: py_compile passed, ruff passed.
2026-06-04 10:00:10 +02:00
alex 0127d283a6 docs: update refactoring spec with submodule architecture
Enforces max 5-10 files per directory using proper subpackages:
- api/tool/, api/config/, api/workspace/, api/user/, api/project/, api/system/
- services/docker/, services/instance/, services/config/, services/git/,
  services/build/, services/terminal/, services/shared/
- models/tool/, models/config/, models/user/, models/project/, models/system/
- schemas/tool/, schemas/config/, schemas/user/, schemas/project/, schemas/system/

Updated design.md module map and tasks.md with 9 phases.
2026-06-04 09:49:55 +02:00
alex 2757ef3b4f docs: add OpenSpec change for backend-frontend refactoring
Recovers and adapts the structural refactoring from overwritten
main merge (b6f89f9) to current dev reality.

Scope:
- Schema extraction into apps/api/src/schemas/
- Docker service split into services/docker/ package
- Instance lifecycle extraction from api/tool_instances.py
- Config profile service extraction from api/config_profiles.py
- Auth dependency refactor (get_current_user)
- Frontend reorganization into features/ dirs + kebab-case naming

Exclusions (already in dev): seeding, defaults, unique constraint,
SSH key mounting, terminal backend, tunnel regex, session auto-numbering.
2026-06-04 09:42:03 +02:00
alex ab55da280c fix: include wait-for-db.sh in Docker build context
The .dockerignore added in 8a0d82f incorrectly excluded wait-for-db.sh,
but the Dockerfile copies it as the container entrypoint. This caused
the Docker build to fail with 'failed to compute cache key: not found'.

Quality gates: verified file exists, py_compile passed.
2026-06-04 00:04:25 +02:00
alex 4e076c36d2 feat: add missing features from main merge
1. Built-in tool type seeding (apps/api/src/seeds/builtin_tool_types.py):
   - Seeds code-server, jupyter-notebook, and opencode on startup.
   - Adapts to current dev model: uses interface_type (single string)
     instead of interfaces array, and created_by_id=None instead of
     is_builtin flag.
   - Called from main.py startup event.

2. Config profile default management:
   - Adds default_profile_id and default_profiles properties to
     UserConfig model for JSON-backed per-tool-type defaults.
   - Adds GET /config-profiles/defaults, PUT /config-profiles/defaults,
     and GET /config-profiles/defaults/{tool_type_id} endpoints.
   - Validates that all profile IDs in default mappings belong to the
     authenticated user before persisting.

3. Config profile unique constraint:
   - Adds __table_args__ with UniqueConstraint(user_id, name) to
     ConfigProfile model. The constraint already exists in the DB
     from migration 2026_05_24_add_config_profiles.py; this just
     aligns the SQLAlchemy model with the schema.

Quality gates: py_compile passed, ruff passed on all modified files.
2026-06-04 00:00:23 +02:00
alex c6d62f84da fix: port useful fixes from overwritten main merge
From ae02e97 ('fix: tunnel URLs, session naming, git control bar placement'):

1. Tunnel URL regex: exclude api.trycloudflare.com from pattern.
   Real tunnel subdomains are 10+ random chars. Prevents matching the
   Cloudflare API endpoint instead of the actual tunnel URL.

2. Session auto-numbering: when user doesn't provide a display_name,
   auto-generate 'project / repo / tool_type #N' where N increments
   for each existing instance with the same project/repo/tool_type.
   Prevents confusing duplicate display names in the sidebar.

These fixes were lost when main's merge was overwritten. Ported to
our clean dev codebase.

Quality gates: py_compile passed, ruff passed on tool_instances.py and tunnel.py.
2026-06-03 23:35:16 +02:00
alex 8a0d82f49b fix: add .dockerignore and src volume mount for live code sync
Add .dockerignore to exclude __pycache__, .venv, test artifacts, and
other host-only files from Docker build context. Prevents stale .pyc
cache pollution in container images.

Add read-only bind mount for ./apps/api/src:/app/src in docker-compose.yml
so code changes on the host are reflected in the running container
without requiring image rebuild. This is a dev convenience that resolves
the persistent 'tool_configs' import error from stale container images.

Quality gates: docker-compose.yml syntax valid, .dockerignore parsed.
2026-06-03 23:19:45 +02:00
alex 0c74997cfe fix: add missing alembic merge migration for DB convergence
The production DB was already migrated to 86cec91fdb00 (merge of
0014_add_profile_resolver_fields and 2026_06_01_add_workspaces) during
earlier fixes. The clean base branch lacked these migration files,
causing startup failure: 'Can't locate revision identified by 86cec91fdb00'.

Copy the idempotent 0013/0014 migrations and the no-op merge revision
from the fix commits so the Alembic graph matches the DB state.

Quality gates: alembic heads returns single head (86cec91fdb00),
py_compile and ruff passed on all three files.
2026-06-03 22:23:19 +02:00
alex fc72c5f6e9 fix: resolve mapper configuration for ToolDefinitionManifest and Workspace
Move ToolDefinitionManifest import out of TYPE_CHECKING in tool_type.py
so SQLAlchemy can resolve the string-annotated relationship during mapper
configuration.

Add Workspace to models/__init__.py (before ToolInstance) so the
ToolInstance-Workspace relationship can be resolved.

Quality gates: py_compile passed, ruff passed, all mappers configure OK.
2026-06-03 14:24:22 +02:00
334 changed files with 21339 additions and 20729 deletions
+5 -4
View File
@@ -48,8 +48,9 @@ apps/web/dist/
# OS
.DS_Store
Thumbs.db
# Local runtime state
.atl/
/.stoneforge/.worktrees/
# Pi / agent cache
.pi/
swap-pane
.atl/
.sisyphus/
.pi-lens/
-7
View File
@@ -90,13 +90,6 @@ Before completion, report:
Do not claim completion without verification evidence.
## Git branch policy
- **Default working branch:** `dev` — all commits and pushes target `dev` unless the user explicitly requests otherwise.
- `main` is the stable/production branch; merge to `main` only when explicitly instructed.
- After committing, push to `origin/dev`.
- If `dev` does not exist locally, create it from `main` or fetch it from origin.
## Git workflow
### Branching strategy
-1
View File
@@ -21,7 +21,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **User Settings** - Theme selection, git identity, and preference management
- **SSH Key Management** - Ed25519 key generation with secure storage
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
- **Config Profiles** - User-owned profile CRUD with includes, mounts, path validation, cycle detection, and default profile selection
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
### Changed
+42
View File
@@ -0,0 +1,42 @@
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.so
# Virtual environments
.venv/
venv/
env/
# Test artifacts
.pytest_cache/
.coverage
htmlcov/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Git
.git/
.gitignore
# Local env files
.env
.env.local
# Alembic cache
alembic/versions/__pycache__/
# Pi lens cache
.pi-lens/
# Documentation
docs/
*.md
# Scripts not needed in container
scripts/
+1 -6
View File
@@ -1,6 +1 @@
from src.api.auth import router as auth_router
from src.api.events import router as events_router
from src.api.notifications import router as notifications_router
from src.api.users import router as users_router
__all__ = ["auth_router", "events_router", "notifications_router", "users_router"]
"""API routers package."""
+6
View File
@@ -0,0 +1,6 @@
"""Config API routers module."""
from src.api.config.config_profiles import router as config_profiles_router
from src.api.config.user_config import router as user_config_router
__all__ = ["config_profiles_router", "user_config_router"]
+842
View File
@@ -0,0 +1,842 @@
"""Config profile API endpoints."""
import logging
import os
import subprocess
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import ConfigProfile, ConfigProfileInclude
from src.models.project import Project
from src.models import ToolType
from src.models import UserConfig
from src.schemas.config import (
ConfigProfileCreate,
ConfigProfileIncludeUpdate,
ConfigProfileResponse,
ConfigProfileUpdate,
DefaultProfilesUpdate,
ValidateGitUrlRequest,
ValidateGitUrlResponse,
)
from src.services.config.config_profile_resolver import (
ConfigProfileCycleError,
check_include_cycle,
resolve_profile,
resolved_profile_to_dict,
)
from src.utils.git_url_parser import parse_git_url
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
MAX_PROFILE_SIZE_MB = 10
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
def _calculate_profile_size(data: dict) -> int:
"""Calculate approximate serialized size of profile data."""
total = 0
for key, value in data.get("env_vars", {}).items():
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
for key, value in data.get("runtime_hints", {}).items():
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
for mount in data.get("mounts", []):
total += len(str(mount.get("target", "")).encode("utf-8"))
total += len(str(mount.get("mode", "")).encode("utf-8"))
for path, content in mount.get("files", {}).items():
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
for path, content in data.get("files", {}).items():
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
return total
async def _get_profile_with_includes(
session: AsyncSession, profile_id: uuid.UUID
) -> ConfigProfile | None:
"""Fetch a profile with includes eagerly loaded."""
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile_id)
.options(selectinload(ConfigProfile.includes))
)
return result.scalar_one_or_none()
async def _check_access(
session: AsyncSession,
user_id: uuid.UUID,
project_id: uuid.UUID | None = None,
tool_type_id: uuid.UUID | None = None,
) -> None:
"""Verify user has access to referenced project and tool type."""
if project_id is not None:
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
# Add ownership check if needed; for now just verify existence
if tool_type_id is not None:
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found"
)
async def _validate_git_mounts(
session: AsyncSession,
user_id: uuid.UUID,
git_mounts: list[Any],
project_id: uuid.UUID | None = None,
) -> None:
"""Validate git mount URLs.
Simply checks that remote_url looks like a valid git URL.
Actual clone validation happens at instance startup time.
"""
for mount in git_mounts:
remote_url = mount.get("remote_url")
if not remote_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Git mount missing remote_url",
)
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid git URL: {remote_url}",
)
def _profile_to_response(
profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None
) -> dict:
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"project_id": str(profile.project_id) if profile.project_id else None,
"tool_type_id": str(profile.tool_type_id) if profile.tool_type_id else None,
"env_vars": profile.env_vars or {},
"runtime_hints": profile.runtime_hints or {},
"mounts": profile.mounts or [],
"git_mounts": profile.git_mounts or [],
"files": profile.files or {},
"is_default": profile.is_default,
"includes": [
{
"id": str(inc.id),
"included_profile_id": str(inc.included_profile_id),
"order_index": inc.order_index,
}
for inc in (includes or profile.includes)
],
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@router.get("", response_model=list[ConfigProfileResponse])
async def list_config_profiles(
project_id: str | None = Query(None, description="Filter by project compatibility"),
tool_type_id: str | None = Query(
None, description="Filter by tool type compatibility"
),
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""List config profiles, optionally filtered by compatibility."""
user_uuid = current_user_id
query = (
select(ConfigProfile)
.where(ConfigProfile.user_id == user_uuid)
.options(selectinload(ConfigProfile.includes))
)
if project_id or tool_type_id:
# Compatibility filter: include portable profiles and matching scoped profiles
project_uuid = uuid.UUID(project_id) if project_id else None
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
from sqlalchemy import or_
conditions: list = []
# Portable profiles (no project, no tool)
conditions.append(
(ConfigProfile.project_id.is_(None))
& (ConfigProfile.tool_type_id.is_(None))
)
if project_uuid:
# Profiles matching this project (with or without tool)
conditions.append(ConfigProfile.project_id == project_uuid)
if tool_uuid:
# Profiles matching this tool (with or without project)
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
if project_uuid and tool_uuid:
# Exact match
conditions.append(
(ConfigProfile.project_id == project_uuid)
& (ConfigProfile.tool_type_id == tool_uuid)
)
query = query.where(or_(*conditions))
result = await session.execute(query)
profiles = result.scalars().all()
return [_profile_to_response(p) for p in profiles]
@router.post(
"", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED
)
async def create_config_profile(
data: ConfigProfileCreate,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Create a new config profile."""
user_uuid = current_user_id
# Check for duplicate name
existing = await session.execute(
select(ConfigProfile)
.where(
ConfigProfile.user_id == user_uuid,
ConfigProfile.name == data.name,
)
.options(selectinload(ConfigProfile.includes))
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Profile with name '{data.name}' already exists",
)
# Validate references
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
await _check_access(session, user_uuid, project_uuid, tool_uuid)
# Validate git mounts reference existing repositories
if data.git_mounts:
git_mounts_data = [
m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts
]
await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
# Check size
size = _calculate_profile_size(data.model_dump())
if size > MAX_PROFILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
)
profile = ConfigProfile(
user_id=user_uuid,
name=data.name,
description=data.description,
project_id=project_uuid,
tool_type_id=tool_uuid,
env_vars=data.env_vars,
runtime_hints=data.runtime_hints,
mounts=[m.model_dump() for m in data.mounts],
git_mounts=[m.model_dump() for m in data.git_mounts],
files=data.files,
is_default=data.is_default,
)
session.add(profile)
await session.commit()
# Re-fetch with includes to avoid lazy loading issues
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
.options(selectinload(ConfigProfile.includes))
)
profile = result.scalar_one()
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
return _profile_to_response(profile)
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
async def get_config_profile(
profile_id: str,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Get a config profile by ID."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
)
if profile.user_id != current_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
return _profile_to_response(profile)
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
async def update_config_profile(
profile_id: str,
data: ConfigProfileUpdate,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Update a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
)
if profile.user_id != current_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
update_data = data.model_dump(exclude_unset=True)
# Handle name uniqueness
if "name" in update_data:
existing = await session.execute(
select(ConfigProfile).where(
ConfigProfile.user_id == profile.user_id,
ConfigProfile.name == update_data["name"],
ConfigProfile.id != profile.id,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Profile with name '{update_data['name']}' already exists",
)
# Validate references
project_uuid = (
uuid.UUID(update_data["project_id"])
if "project_id" in update_data and update_data["project_id"]
else (profile.project_id if "project_id" not in update_data else None)
)
tool_uuid = (
uuid.UUID(update_data["tool_type_id"])
if "tool_type_id" in update_data and update_data["tool_type_id"]
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
)
await _check_access(session, profile.user_id, project_uuid, tool_uuid)
# Validate git mounts reference existing repositories
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
git_mounts_data = [
m.model_dump() if hasattr(m, "model_dump") else m
for m in update_data["git_mounts"]
]
await _validate_git_mounts(
session, profile.user_id, git_mounts_data, project_uuid
)
# Check size
current_data = _profile_to_response(profile)
merged = {**current_data, **update_data}
size = _calculate_profile_size(merged)
if size > MAX_PROFILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
)
# Apply updates
for field_name, value in update_data.items():
if field_name in ("project_id", "tool_type_id"):
value = uuid.UUID(value) if value else None
elif field_name == "mounts" and value is not None:
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
elif field_name == "git_mounts" and value is not None:
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
setattr(profile, field_name, value)
await session.commit()
# Re-fetch with includes to avoid lazy loading issues
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
.options(selectinload(ConfigProfile.includes))
)
profile = result.scalar_one()
logger.debug("Updated config profile %s", profile.id)
return _profile_to_response(profile)
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config_profile(
profile_id: str,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Delete a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
)
if profile.user_id != current_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
await session.delete(profile)
await session.commit()
logger.debug("Deleted config profile %s", profile_id)
return None
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
async def update_profile_includes(
profile_id: str,
data: ConfigProfileIncludeUpdate,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Update the ordered includes for a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
)
if profile.user_id != current_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
# Validate all included profiles exist and belong to the user
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
for inc_uuid in included_uuids:
inc_profile = await session.get(ConfigProfile, inc_uuid)
if inc_profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Included profile not found: {inc_uuid}",
)
if inc_profile.user_id != current_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Not authorized to include profile: {inc_uuid}",
)
if inc_uuid == profile.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Profile cannot include itself",
)
# Check for cycles
cycle = await check_include_cycle(session, profile.id, None)
if cycle is None and included_uuids:
# Check each new include would not create a cycle
for inc_uuid in included_uuids:
cycle = await check_include_cycle(session, profile.id, inc_uuid)
if cycle is not None:
break
if cycle is not None:
cycle_str = " -> ".join(str(c) for c in cycle)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Include cycle detected: {cycle_str}",
)
# Remove existing includes
result = await session.execute(
select(ConfigProfileInclude).where(
ConfigProfileInclude.profile_id == profile.id
)
)
for existing in result.scalars().all():
await session.delete(existing)
await session.flush()
# Add new includes
for order_index, inc_uuid in enumerate(included_uuids):
include = ConfigProfileInclude(
profile_id=profile.id,
included_profile_id=inc_uuid,
order_index=order_index,
)
session.add(include)
await session.flush()
await session.commit()
# Re-fetch profile (includes loaded separately due to SQLite async issue)
result = await session.execute(
select(ConfigProfile).where(ConfigProfile.id == profile.id)
)
profile = result.scalar_one()
inc_result = await session.execute(
select(ConfigProfileInclude).where(
ConfigProfileInclude.profile_id == profile.id
)
)
direct_includes = inc_result.scalars().all()
logger.debug("Updated includes for config profile %s", profile.id)
return _profile_to_response(profile, list(direct_includes))
@router.get("/{profile_id}/preview")
async def preview_config_profile(
profile_id: str,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Preview the resolved output of a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
)
if profile.user_id != current_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
try:
resolved = await resolve_profile(session, profile.id)
except ConfigProfileCycleError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
return resolved_profile_to_dict(resolved)
@router.get("/defaults/resolve")
async def resolve_default_profile(
project_id: str = Query(..., description="Project ID"),
tool_type_id: str = Query(..., description="Tool type ID"),
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Resolve the default config profile for a project/tool combination.
Selects by specificity:
1. project+tool explicit default
2. project explicit default
3. tool explicit default
4. global/user explicit default
5. first created compatible profile
6. none (returns null)
"""
user_uuid = current_user_id
project_uuid = uuid.UUID(project_id)
tool_uuid = uuid.UUID(tool_type_id)
# Fetch all compatible profiles ordered by created_at
query = (
select(ConfigProfile)
.where(ConfigProfile.user_id == user_uuid)
.where(
(ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None))
| (ConfigProfile.project_id == project_uuid)
| (ConfigProfile.tool_type_id == tool_uuid)
| (
(ConfigProfile.project_id == project_uuid)
& (ConfigProfile.tool_type_id == tool_uuid)
)
)
.order_by(ConfigProfile.created_at)
)
result = await session.execute(query)
profiles = result.scalars().all()
if not profiles:
return {"profile_id": None, "profile_name": None}
# Check explicit defaults by specificity
explicit_defaults = [p for p in profiles if p.is_default]
# Most specific: project+tool
for p in explicit_defaults:
if p.project_id == project_uuid and p.tool_type_id == tool_uuid:
return {"profile_id": str(p.id), "profile_name": p.name}
# Next: project only
for p in explicit_defaults:
if p.project_id == project_uuid and p.tool_type_id is None:
return {"profile_id": str(p.id), "profile_name": p.name}
# Next: tool only
for p in explicit_defaults:
if p.project_id is None and p.tool_type_id == tool_uuid:
return {"profile_id": str(p.id), "profile_name": p.name}
# Next: global/user (no project, no tool)
for p in explicit_defaults:
if p.project_id is None and p.tool_type_id is None:
return {"profile_id": str(p.id), "profile_name": p.name}
# Fall back to first created compatible profile
first = profiles[0]
return {"profile_id": str(first.id), "profile_name": first.name}
# ---------------------------------------------------------------------------
# Default profile management
# ---------------------------------------------------------------------------
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():
try:
profile_uuid = uuid.UUID(profile_id_str)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid profile ID for tool type {tool_type_id}: {profile_id_str}",
)
profile = await session.get(ConfigProfile, profile_uuid)
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Profile not found: {profile_id_str}",
)
if profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Profile does not belong to user: {profile_id_str}",
)
@router.get("/defaults")
async def get_default_profiles_endpoint(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get all default profile mappings for the current 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 {}}
@router.put("/defaults")
async def set_default_profiles_endpoint(
data: DefaultProfilesUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Set default profile mappings for the current user."""
await _validate_default_profiles(session, user_id, data.default_profiles)
user_config = await _get_or_create_user_config(session, user_id)
user_config.config = {
**user_config.config,
"default_profiles": data.default_profiles,
}
await session.commit()
await session.refresh(user_config)
return {"default_profiles": user_config.default_profiles}
@router.get("/defaults/{tool_type_id}")
async def get_default_profile_for_tool_type_endpoint(
tool_type_id: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get the default profile ID 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}
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
async def validate_git_url(
data: ValidateGitUrlRequest,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ValidateGitUrlResponse:
"""Validate a git remote URL and list available branches.
Parses the URL, suggests corrections for browser URLs, and runs
git ls-remote to verify reachability and enumerate branches.
"""
parse_result = parse_git_url(data.url)
original_url = data.url.strip()
url_to_check = parse_result.get("base_url") or original_url
if not url_to_check:
return ValidateGitUrlResponse(
valid=False,
error=parse_result.get("message", "Invalid URL"),
error_code=parse_result.get("error_code", "INVALID_URL"),
)
# If the URL needed parsing, return suggestion without checking remote
if parse_result.get("needs_parsing") and url_to_check != original_url:
return ValidateGitUrlResponse(
valid=False,
suggested_url=url_to_check,
error=parse_result.get("message"),
error_code=parse_result.get("error_code", "URL_NEEDS_PARSING"),
)
# Optional SSH key for private repos
env = None
key_path = None
if data.ssh_key_id:
from src.models import SSHKey
from src.services.shared.ssh_keys import _get_fernet
try:
ssh_key_uuid = uuid.UUID(data.ssh_key_id)
except ValueError:
return ValidateGitUrlResponse(
valid=False,
error="Invalid SSH key ID format",
error_code="INVALID_SSH_KEY",
)
ssh_key = await session.get(SSHKey, ssh_key_uuid)
if ssh_key is None or ssh_key.user_id != current_user_id:
return ValidateGitUrlResponse(
valid=False,
error="SSH key not found or not authorized",
error_code="SSH_KEY_NOT_FOUND",
)
import tempfile
fernet = _get_fernet()
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
try:
os.write(fd, private_key.encode())
finally:
os.close(fd)
os.chmod(key_path, 0o600)
env = {
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
}
try:
result = subprocess.run(
["git", "ls-remote", "--heads", url_to_check],
capture_output=True,
text=True,
timeout=30,
env={**os.environ, **env} if env else None,
)
except subprocess.TimeoutExpired:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
return ValidateGitUrlResponse(
valid=False,
error="Remote repository check timed out",
error_code="TIMEOUT",
)
except FileNotFoundError:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
return ValidateGitUrlResponse(
valid=False,
error="git command not found on server",
error_code="GIT_NOT_FOUND",
)
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
if result.returncode != 0:
stderr = result.stderr.strip()
if (
"could not resolve" in stderr.lower()
or "unable to access" in stderr.lower()
):
error_msg = "Could not reach repository. Check the URL and network access."
error_code = "UNREACHABLE"
elif (
"authentication" in stderr.lower() or "permission denied" in stderr.lower()
):
error_msg = (
"Authentication failed. Provide an SSH key for private repositories."
)
error_code = "AUTH_FAILED"
else:
error_msg = f"Repository not accessible: {stderr[:200]}"
error_code = "REMOTE_ERROR"
return ValidateGitUrlResponse(
valid=False,
error=error_msg,
error_code=error_code,
)
# Parse branches from ls-remote output
branches: list[str] = []
default_branch = "main"
for line in result.stdout.strip().split("\n"):
if not line.strip():
continue
parts = line.split()
if len(parts) == 2:
ref = parts[1]
# refs/heads/branch-name
if ref.startswith("refs/heads/"):
branch_name = ref[len("refs/heads/") :]
branches.append(branch_name)
if branch_name in ("main", "master"):
default_branch = branch_name
if not branches:
return ValidateGitUrlResponse(
valid=False,
error="No branches found in remote repository",
error_code="NO_BRANCHES",
)
return ValidateGitUrlResponse(
valid=True,
suggested_url=url_to_check if url_to_check != original_url else None,
branches=branches,
default_branch=default_branch,
)
@@ -1,20 +1,22 @@
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user, get_db_session
from src.models.user import User
from src.models.user_config import UserConfig
from src.schemas.user_config import UserConfigResponse, UserConfigUpdate
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models import UserConfig
from src.schemas.user import UserConfigResponse, UserConfigUpdate
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/users/me", tags=["user-config"])
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
async def _get_or_create_config(
session: AsyncSession, user_id: uuid.UUID
) -> UserConfig:
"""Get or create user config record.
Args:
@@ -24,10 +26,12 @@ async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> Us
Returns:
The user's config, creating a new one if it doesn't exist.
"""
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user.id))
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
config = result.scalar_one_or_none()
if config is None:
config = UserConfig(user_id=user.id, config={})
config = UserConfig(user_id=user_id, config={})
session.add(config)
await session.commit()
await session.refresh(config)
@@ -41,7 +45,7 @@ async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> Us
description="Get the current user's configuration settings.",
)
async def get_user_config(
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> UserConfigResponse:
"""Get the current user's configuration.
@@ -53,7 +57,8 @@ async def get_user_config(
Returns:
The user's configuration settings.
"""
config = await _get_or_create_config(session, user.id)
_user = await _get_user(session, user_id)
config = await _get_or_create_config(session, user_id)
return UserConfigResponse.model_validate(config.config)
@@ -65,7 +70,7 @@ async def get_user_config(
)
async def update_user_config(
data: UserConfigUpdate,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> UserConfigResponse:
"""Update the current user's configuration.
@@ -78,15 +83,16 @@ async def update_user_config(
Returns:
The updated user configuration.
"""
config = await _get_or_create_config(session, user.id)
_user = await _get_user(session, user_id)
config = await _get_or_create_config(session, user_id)
# Merge updates
update_data = data.model_dump(exclude_unset=True)
logger.info("Updating user config for user %s: %s", user.id, update_data)
logger.debug("Updating user config for user %s: %s", user_id, update_data)
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
config.config = {**config.config, **update_data}
await session.commit()
await session.refresh(config)
logger.info("Updated config: %s", config.config)
logger.debug("Updated config: %s", config.config)
return UserConfigResponse.model_validate(config.config)
-299
View File
@@ -1,299 +0,0 @@
"""Config profile API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.auth.dependencies import get_current_user_id, get_db_session
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
from src.schemas.config_profile import (
ConfigIncludeCreate,
ConfigIncludeUpdate,
ConfigMountCreate,
ConfigMountUpdate,
ConfigProfileCreate,
ConfigProfileUpdate,
DefaultProfilesUpdate,
)
from src.services.config_profiles import (
check_duplicate_include,
check_duplicate_mount_path,
check_duplicate_name,
get_default_profile_for_tool_type,
get_default_profiles,
get_owned_profile,
include_to_dict,
list_includes_for_profile,
list_mounts_for_profile,
mount_to_dict,
profile_to_dict,
set_default_profiles,
validate_includes_no_cycle,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
@router.get("")
async def list_config_profiles(
tool_type_id: str | None = None,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
query = select(ConfigProfile).where(ConfigProfile.user_id == user_id)
if tool_type_id:
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
result = await session.execute(query.order_by(ConfigProfile.name))
return {"profiles": [profile_to_dict(p) for p in result.scalars().all()]}
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_config_profile(
data: ConfigProfileCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
await check_duplicate_name(session, user_id, data.name)
profile = ConfigProfile(user_id=user_id, name=data.name, description=data.description)
session.add(profile)
await session.commit()
await session.refresh(profile)
return profile_to_dict(profile)
@router.get("/defaults")
async def get_default_profiles_endpoint(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
return await get_default_profiles(session, user_id)
@router.put("/defaults")
async def set_default_profiles_endpoint(
data: DefaultProfilesUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
return await set_default_profiles(session, user_id, data.default_profiles)
@router.get("/defaults/{tool_type_id}")
async def get_default_profile_for_tool_type_endpoint(
tool_type_id: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
return await get_default_profile_for_tool_type(session, user_id, tool_type_id)
@router.get("/{profile_id}")
async def get_config_profile(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
profile = await session.get(
ConfigProfile,
profile_id,
options=[selectinload(ConfigProfile.includes), selectinload(ConfigProfile.mounts)],
)
if profile is None or profile.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config profile not found")
includes_data = []
for inc in profile.includes:
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 {
**profile_to_dict(profile),
"includes": includes_data,
"mounts": [mount_to_dict(m) for m in profile.mounts],
}
@router.put("/{profile_id}")
async def update_config_profile(
profile_id: uuid.UUID,
data: ConfigProfileUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
profile = await get_owned_profile(profile_id, user_id, session)
if data.name is not None:
await check_duplicate_name(session, user_id, data.name, exclude_id=profile_id)
profile.name = data.name
if data.description is not None:
profile.description = data.description
await session.commit()
await session.refresh(profile)
return profile_to_dict(profile)
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config_profile(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
profile = await get_owned_profile(profile_id, user_id, session)
await session.delete(profile)
await session.commit()
@router.get("/{profile_id}/includes")
async def list_profile_includes(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
await get_owned_profile(profile_id, user_id, session)
return await list_includes_for_profile(session, profile_id)
@router.post("/{profile_id}/includes", status_code=status.HTTP_201_CREATED)
async def add_profile_include(
profile_id: uuid.UUID,
data: ConfigIncludeCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
profile = await get_owned_profile(profile_id, user_id, session)
included_profile_id = uuid.UUID(data.included_profile_id)
if included_profile_id == profile_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="a profile cannot include itself")
included_profile = await session.get(ConfigProfile, included_profile_id)
if included_profile is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="included profile not found")
if included_profile.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="included profile does not belong to user")
await check_duplicate_include(session, profile_id, included_profile_id)
await validate_includes_no_cycle(session, profile_id, included_profile_id)
include = ConfigInclude(
profile_id=profile_id,
included_profile_id=included_profile_id,
order_index=data.order_index,
)
session.add(include)
await session.commit()
await session.refresh(include)
return include_to_dict(include, included_profile.name)
@router.put("/{profile_id}/includes/{include_id}")
async def update_profile_include(
profile_id: uuid.UUID,
include_id: uuid.UUID,
data: ConfigIncludeUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
await get_owned_profile(profile_id, user_id, session)
include = await session.get(ConfigInclude, include_id)
if include is None or include.profile_id != profile_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found")
include.order_index = data.order_index
await session.commit()
await session.refresh(include)
included_profile = await session.get(ConfigProfile, include.included_profile_id)
return include_to_dict(include, included_profile.name if included_profile else None)
@router.delete("/{profile_id}/includes/{include_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_profile_include(
profile_id: uuid.UUID,
include_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
await get_owned_profile(profile_id, user_id, session)
include = await session.get(ConfigInclude, include_id)
if include is None or include.profile_id != profile_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found")
await session.delete(include)
await session.commit()
@router.get("/{profile_id}/mounts")
async def list_profile_mounts(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
await get_owned_profile(profile_id, user_id, session)
return await list_mounts_for_profile(session, profile_id)
@router.post("/{profile_id}/mounts", status_code=status.HTTP_201_CREATED)
async def add_profile_mount(
profile_id: uuid.UUID,
data: ConfigMountCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
profile = await get_owned_profile(profile_id, user_id, session)
await check_duplicate_mount_path(session, profile_id, data.target_path)
mount = ConfigMount(
profile_id=profile_id,
target_path=data.target_path,
mode=data.mode,
files=data.files,
order_index=data.order_index,
)
session.add(mount)
await session.commit()
await session.refresh(mount)
return mount_to_dict(mount)
@router.put("/{profile_id}/mounts/{mount_id}")
async def update_profile_mount(
profile_id: uuid.UUID,
mount_id: uuid.UUID,
data: ConfigMountUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
await get_owned_profile(profile_id, user_id, session)
mount = await session.get(ConfigMount, mount_id)
if mount is None or mount.profile_id != profile_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found")
if data.target_path is not None:
await check_duplicate_mount_path(session, profile_id, data.target_path, exclude_id=mount_id)
mount.target_path = data.target_path
if data.files is not None:
mount.files = data.files
if data.order_index is not None:
mount.order_index = data.order_index
await session.commit()
await session.refresh(mount)
return mount_to_dict(mount)
@router.delete("/{profile_id}/mounts/{mount_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_profile_mount(
profile_id: uuid.UUID,
mount_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
await get_owned_profile(profile_id, user_id, session)
mount = await session.get(ConfigMount, mount_id)
if mount is None or mount.profile_id != profile_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found")
await session.delete(mount)
await session.commit()
-290
View File
@@ -1,290 +0,0 @@
"""Git repository API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user, get_db_session, get_owned_project
from src.models.project import Project
from src.models.user import User
from src.schemas.git_repository import (
BranchCreateRequest,
CheckoutRequest,
CommitRequest,
CommitResponse,
FetchResponse,
FileContentResponse,
FileListResponse,
FileUpdateRequest,
FileUpdateResponse,
GitRepositoryCreate,
GitRepositoryResponse,
MergeRequest,
MergeResponse,
PullResponse,
PushResponse,
StatusResponse,
URLParseRequest,
URLParseResponse,
)
from src.services.git import control as git_control
from src.services.git import files as git_files
from src.services.git.repository import create_repository, delete_repository, list_repositories
from src.utils.git_url_parser import parse_git_url
router = APIRouter(prefix="/projects", tags=["git-repositories"])
logger = logging.getLogger(__name__)
@router.get("/{project_id}/repositories", response_model=list[GitRepositoryResponse])
async def list_repositories_endpoint(
project_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
):
return await list_repositories(session, project_id)
@router.post("/{project_id}/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED)
async def create_repository_endpoint(
project_id: uuid.UUID,
data: GitRepositoryCreate,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
):
return await create_repository(session, project_id, data, user)
@router.delete("/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_repository_endpoint(
project_id: uuid.UUID,
repo_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
):
await delete_repository(session, repo_id, project_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/repositories/parse-url", response_model=URLParseResponse)
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
return URLParseResponse(**parse_git_url(data.url))
# History
@router.get("/{project_id}/repositories/{repo_id}/history")
async def get_repository_history(
project_id: uuid.UUID,
repo_id: uuid.UUID,
view: str = "graph",
branch: str | None = None,
limit: int = 100,
offset: int = 0,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
from src.utils.git_history import get_commit_history
from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk
repo = await get_repo_and_validate(session, repo_id, project_id)
ensure_repo_on_disk(repo)
try:
return get_commit_history(repo.path, branch=branch, limit=limit, offset=offset)
except RuntimeError as e:
logger.warning("Git history failed for %s: %s", repo.path, str(e))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Git history unavailable: {str(e)}",
) from e
@router.get("/{project_id}/repositories/{repo_id}/commits/{commit_hash}")
async def get_repository_commit(
project_id: uuid.UUID,
repo_id: uuid.UUID,
commit_hash: str,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
from src.utils.git_history import get_commit_detail
from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk
repo = await get_repo_and_validate(session, repo_id, project_id)
ensure_repo_on_disk(repo)
try:
return get_commit_detail(repo.path, commit_hash)
except RuntimeError as e:
logger.warning("Git commit detail failed for %s %s: %s", repo.path, commit_hash, str(e))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Commit detail unavailable: {str(e)}",
) from e
# File browsing
@router.get("/{project_id}/repositories/{repo_id}/files", response_model=FileListResponse)
async def list_repository_files(
project_id: uuid.UUID,
repo_id: uuid.UUID,
branch: str = "main",
path: str = "",
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> FileListResponse:
return await git_files.list_files(session, project_id, repo_id, branch, path)
@router.get("/{project_id}/repositories/{repo_id}/files/content", response_model=FileContentResponse)
async def get_repository_file_content(
project_id: uuid.UUID,
repo_id: uuid.UUID,
branch: str,
path: str,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> FileContentResponse:
return await git_files.get_file(session, project_id, repo_id, branch, path)
@router.post("/{project_id}/repositories/{repo_id}/files/content", response_model=FileUpdateResponse)
async def update_repository_file(
project_id: uuid.UUID,
repo_id: uuid.UUID,
data: FileUpdateRequest,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> FileUpdateResponse:
return await git_files.update_file(session, project_id, repo_id, data, user)
# Branches
@router.get("/{project_id}/repositories/{repo_id}/branches")
async def get_repository_branches(
project_id: uuid.UUID,
repo_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
return await git_files.list_branches_with_validation(session, project_id, repo_id)
@router.post("/{project_id}/repositories/{repo_id}/branches")
async def create_repository_branch(
project_id: uuid.UUID,
repo_id: uuid.UUID,
data: BranchCreateRequest,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
return await git_control.create_branch_with_validation(session, project_id, repo_id, data)
@router.delete("/{project_id}/repositories/{repo_id}/branches/{branch_name}")
async def delete_repository_branch(
project_id: uuid.UUID,
repo_id: uuid.UUID,
branch_name: str,
force: bool = False,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
return await git_control.delete_branch_with_validation(session, project_id, repo_id, branch_name, force)
@router.post("/{project_id}/repositories/{repo_id}/checkout")
async def checkout_repository_branch(
project_id: uuid.UUID,
repo_id: uuid.UUID,
data: CheckoutRequest,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
return await git_control.checkout_branch_with_validation(session, project_id, repo_id, data)
# Git control
@router.get("/{project_id}/repositories/{repo_id}/status", response_model=StatusResponse)
async def get_repository_status(
project_id: uuid.UUID,
repo_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> StatusResponse:
return await git_control.get_status_with_validation(session, project_id, repo_id)
@router.post("/{project_id}/repositories/{repo_id}/commit", response_model=CommitResponse)
async def commit_repository_changes(
project_id: uuid.UUID,
repo_id: uuid.UUID,
data: CommitRequest,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> CommitResponse:
result = await git_control.commit_changes_with_validation(session, project_id, repo_id, data, user)
return CommitResponse(commit_hash=result["commit_hash"], message=result["message"])
@router.post("/{project_id}/repositories/{repo_id}/fetch", response_model=FetchResponse)
async def fetch_repository(
project_id: uuid.UUID,
repo_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> FetchResponse:
return await git_control.fetch_with_validation(session, project_id, repo_id)
@router.post("/{project_id}/repositories/{repo_id}/pull", response_model=PullResponse)
async def pull_repository(
project_id: uuid.UUID,
repo_id: uuid.UUID,
branch: str | None = None,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> PullResponse:
return await git_control.pull_with_validation(session, project_id, repo_id, branch)
@router.post("/{project_id}/repositories/{repo_id}/push", response_model=PushResponse)
async def push_repository(
project_id: uuid.UUID,
repo_id: uuid.UUID,
branch: str | None = None,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> PushResponse:
return await git_control.push_with_validation(session, project_id, repo_id, branch)
@router.post("/{project_id}/repositories/{repo_id}/merge", response_model=MergeResponse)
async def merge_repository_branches(
project_id: uuid.UUID,
repo_id: uuid.UUID,
data: MergeRequest,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> MergeResponse:
return await git_control.merge_with_validation(session, project_id, repo_id, data)
+6
View File
@@ -0,0 +1,6 @@
"""Project API routers module."""
from src.api.project.git_repositories import router as git_repositories_router
from src.api.project.projects import router as projects_router
__all__ = ["git_repositories_router", "projects_router"]
File diff suppressed because it is too large Load Diff
@@ -3,25 +3,29 @@ import shutil
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user, get_db_session, get_owned_project
from src.models.git_repository import GitRepository
from src.auth.dependencies import (
_get_owned_project,
_get_user,
get_current_user_id,
get_db_session,
)
from src.models import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.user import User
from src.models import SSHKey
from src.models import ToolInstance
from src.schemas.project import (
ProjectCreate,
ProjectUpdate,
ProjectResponse,
ProjectUpdate,
SetDefaultSSHKeyRequest,
)
router = APIRouter(prefix="/projects", tags=["projects"])
@router.post(
"",
response_model=ProjectResponse,
@@ -31,7 +35,7 @@ router = APIRouter(prefix="/projects", tags=["projects"])
)
async def create_project(
data: ProjectCreate,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Create a new project.
@@ -44,6 +48,7 @@ async def create_project(
Returns:
The newly created project.
"""
user = await _get_user(session, user_id)
project = Project(
name=data.name,
description=data.description,
@@ -58,25 +63,77 @@ async def create_project(
@router.get(
"",
response_model=list[ProjectResponse],
summary="List all projects",
description="Retrieve all projects owned by the authenticated user.",
description="Retrieve all projects owned by the authenticated user with repositories and workspaces.",
)
async def list_projects(
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[Project]:
) -> list[dict]:
"""List all projects for the authenticated user.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of projects owned by the user.
Returns projects with nested repositories and workspaces for inline display.
"""
result = await session.execute(select(Project).where(Project.owner_id == user.id))
return list(result.scalars().all())
user = await _get_user(session, user_id)
result = await session.execute(
select(Project)
.where(Project.owner_id == user.id)
.order_by(Project.created_at.desc())
)
projects = result.scalars().all()
from src.models import Workspace
enriched = []
for project in projects:
repos_result = await session.execute(
select(GitRepository).where(GitRepository.project_id == project.id)
)
repositories = []
for repo in repos_result.scalars().all():
ws_result = await session.execute(
select(Workspace).where(Workspace.repo_id == repo.id)
)
workspaces = []
for ws in ws_result.scalars().all():
# Count instances
inst_result = await session.execute(
select(func.count()).where(ToolInstance.workspace_id == ws.id)
)
instance_count = inst_result.scalar() or 0
workspaces.append(
{
"id": str(ws.id),
"name": ws.name,
"branch": ws.branch,
"status": ws.status,
"instance_count": instance_count,
}
)
repositories.append(
{
"id": str(repo.id),
"name": repo.name,
"remote_url": repo.remote_url,
"workspaces": workspaces,
}
)
enriched.append(
{
"id": str(project.id),
"name": project.name,
"description": project.description,
"owner_id": str(project.owner_id),
"repositories": repositories,
"created_at": project.created_at.isoformat()
if project.created_at
else None,
}
)
return enriched
@router.get(
@@ -87,8 +144,7 @@ async def list_projects(
)
async def get_project(
project_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Get a specific project by ID.
@@ -101,8 +157,8 @@ async def get_project(
Returns:
The requested project.
"""
return project
await _get_user(session, user_id)
return await _get_owned_project(project_id, user_id, session)
@router.patch(
@@ -114,8 +170,7 @@ async def get_project(
async def update_project(
project_id: uuid.UUID,
data: ProjectUpdate,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Update a project.
@@ -129,6 +184,8 @@ async def update_project(
Returns:
The updated project.
"""
await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
if data.name is not None:
project.name = data.name
@@ -148,8 +205,7 @@ async def update_project(
)
async def delete_project(
project_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Response:
"""Delete a project and all its repositories.
@@ -162,9 +218,13 @@ async def delete_project(
Returns:
Empty response with 204 status code.
"""
await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
# Delete repositories from disk and database
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
result = await session.execute(
select(GitRepository).where(GitRepository.project_id == project_id)
)
repositories = result.scalars().all()
for repo in repositories:
if os.path.exists(repo.path):
@@ -185,8 +245,7 @@ async def delete_project(
async def set_default_ssh_key(
project_id: uuid.UUID,
data: SetDefaultSSHKeyRequest,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Set the default SSH key for a project.
@@ -200,6 +259,8 @@ async def set_default_ssh_key(
Returns:
The updated project.
"""
user = await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
ssh_key = await session.get(SSHKey, data.ssh_key_id)
if ssh_key is None or ssh_key.user_id != user.id:
+17
View File
@@ -0,0 +1,17 @@
"""System API routers module."""
from src.api.system.dashboard import router as dashboard_router
from src.api.system.events import router as events_router
from src.api.system.health import router as health_router
from src.api.system.instance_proxy import router as instance_proxy_router
from src.api.system.notifications import router as notifications_router
from src.api.system.terminal import router as terminal_router
__all__ = [
"dashboard_router",
"events_router",
"health_router",
"instance_proxy_router",
"notifications_router",
"terminal_router",
]
@@ -5,9 +5,9 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.git_repository import GitRepository
from src.models import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models import SSHKey
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import StreamingResponse
from src.auth.dependencies import get_current_user_id
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload
router = APIRouter(prefix="/events", tags=["events"])
@@ -4,12 +4,11 @@ import time
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, status
from fastapi import APIRouter
from sqlalchemy import text
from src.config import Settings
from src.database import SessionLocal
from src.schemas.health import (
from src.schemas.system import (
DatabaseHealth,
DatabaseHealthResponse,
DiskHealth,
@@ -8,8 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.models import ToolInstance
from src.models import ToolType
logger = logging.getLogger(__name__)
@@ -9,8 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user, get_db_session
from src.models.user import User
from src.models.user_config import UserConfig
from src.services.notification_service import notification_service
from src.models import UserConfig
from src.services.shared.notification_service import notification_service
router = APIRouter(prefix="/notifications", tags=["notifications"])
@@ -12,10 +12,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from starlette.websockets import WebSocketDisconnect
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.terminal_session import TerminalSessionModel
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager
from src.models import TerminalSessionModel
from src.models import ToolInstance
from src.models import ToolType
from src.services.terminal.terminal_manager import MaxSessionsExceededError, terminal_manager
router = APIRouter()
logger = logging.getLogger(__name__)
+13
View File
@@ -0,0 +1,13 @@
"""Tool API routers module."""
from src.api.tool.sessions import sessions_router
from src.api.tool.tool_definitions import router as tool_definitions_router
from src.api.tool.tool_instances import router as tool_instances_router
from src.api.tool.tool_types import router as tool_types_router
__all__ = [
"sessions_router",
"tool_definitions_router",
"tool_instances_router",
"tool_types_router",
]
+80
View File
@@ -0,0 +1,80 @@
"""Sessions API endpoints (running instances for current user)."""
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models import GitRepository
from src.models import Project
from src.models import ToolInstance
from src.models import ToolType
sessions_router = APIRouter(prefix="/users", tags=["sessions"])
@sessions_router.get(
"/me/sessions",
summary="Get user sessions",
description="Get all active sessions (running instances) for the current user.",
)
async def get_user_sessions(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get all active sessions for the current user.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary containing list of active sessions with instance details.
"""
_user = await _get_user(session, user_id)
result = await session.execute(
select(ToolInstance)
.where(ToolInstance.owner_id == user_id)
.where(
ToolInstance.status.in_(
["running", "building", "pending", "stopped", "error"]
)
)
.order_by(ToolInstance.created_at.desc())
)
instances = result.scalars().all()
sessions = []
for instance in instances:
tool_type = await session.get(ToolType, instance.tool_type_id)
repo = await session.get(GitRepository, instance.repository_id)
project = await session.get(Project, instance.project_id)
sessions.append(
{
"id": str(instance.id),
"display_name": instance.display_name,
"tool_type_name": tool_type.name if tool_type else "unknown",
"tool_icon": tool_type.name if tool_type else "code",
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
"repository_name": repo.name if repo else "unknown",
"repository_id": str(instance.repository_id),
"project_name": project.name if project else "unknown",
"project_id": str(instance.project_id),
"status": instance.status,
"url": instance.url,
"clone_mode": instance.clone_mode,
"branch": instance.branch,
"selected_config_profile_id": str(instance.selected_config_profile_id)
if instance.selected_config_profile_id
else None,
"created_at": instance.created_at.isoformat()
if instance.created_at
else None,
}
)
return {"sessions": sessions}
@@ -9,9 +9,9 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_definition_manifest import ToolDefinitionManifest
from src.models.tool_type import ToolType
from src.services.manifest_compiler import (
from src.models import ToolDefinitionManifest
from src.models import ToolType
from src.services.build.manifest_compiler import (
compile_compose,
compile_dockerfile,
compile_entrypoint,
@@ -173,7 +173,7 @@ async def list_tool_definitions(
"""
query = select(ToolDefinitionManifest)
if not include_bases:
query = query.where(ToolDefinitionManifest.is_base == False)
query = query.where(ToolDefinitionManifest.is_base.is_(False))
result = await session.execute(
query.order_by(ToolDefinitionManifest.created_at.desc())
File diff suppressed because it is too large Load Diff
@@ -1,14 +1,18 @@
import uuid
import yaml
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user, get_db_session
from src.models.tool_type import ToolType
from src.api.tool.tool_types_validation import (
check_port_exposed,
validate_compose_yaml,
validate_required_variables,
)
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models import ToolType
from src.models.user import User
from src.schemas.tool_type import (
from src.schemas.tool import (
ToolTypeCreate,
ToolTypeResponse,
ToolTypeUpdate,
@@ -38,7 +42,7 @@ async def _require_admin(user: User) -> None:
)
async def create_tool_type(
data: ToolTypeCreate,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ToolType:
"""Create a new tool type.
@@ -51,6 +55,7 @@ async def create_tool_type(
Returns:
The newly created tool type.
"""
user = await _get_user(session, user_id)
await _require_admin(user)
# Check for duplicate name
@@ -67,13 +72,16 @@ async def create_tool_type(
description=data.description,
default_port=data.default_port,
definition_type=data.definition_type,
manifest_id=data.manifest_id,
compose_template=data.compose_template,
dockerfile_template=data.dockerfile_template,
build_context=data.build_context,
readiness_probe=data.readiness_probe,
startup_command=data.startup_command,
required_variables=data.required_variables,
category=data.category,
interfaces=data.interfaces,
interface_type=data.interface_type,
requires_port=data.requires_port,
created_by_id=user.id,
)
session.add(tool_type)
@@ -89,7 +97,7 @@ async def create_tool_type(
description="List all available tool types including built-in and custom ones.",
)
async def list_tool_types(
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[ToolType]:
"""List all tool types.
@@ -101,6 +109,7 @@ async def list_tool_types(
Returns:
List of all tool types ordered by name.
"""
await _get_user(session, user_id)
result = await session.execute(select(ToolType).order_by(ToolType.name))
return list(result.scalars().all())
@@ -113,7 +122,7 @@ async def list_tool_types(
)
async def get_tool_type(
tool_type_id: uuid.UUID,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ToolType:
"""Get a specific tool type by ID.
@@ -126,6 +135,7 @@ async def get_tool_type(
Returns:
The requested tool type.
"""
await _get_user(session, user_id)
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
raise HTTPException(
@@ -143,7 +153,7 @@ async def get_tool_type(
async def update_tool_type(
tool_type_id: uuid.UUID,
data: ToolTypeUpdate,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ToolType:
"""Update a tool type.
@@ -157,6 +167,7 @@ async def update_tool_type(
Returns:
The updated tool type.
"""
user = await _get_user(session, user_id)
await _require_admin(user)
tool_type = await session.get(ToolType, tool_type_id)
@@ -165,16 +176,13 @@ async def update_tool_type(
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
)
if tool_type.is_builtin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="cannot modify built-in tool types",
)
# Built-in tool types can now be modified
update_data = data.model_dump(exclude_unset=True)
# Validate port if being updated
if "default_port" in update_data:
requires_port = update_data.get("requires_port", tool_type.requires_port)
if "default_port" in update_data and requires_port:
new_port = update_data["default_port"]
if new_port <= 0 or new_port > 65535:
raise HTTPException(
@@ -188,62 +196,35 @@ async def update_tool_type(
template = update_data.get("compose_template", tool_type.compose_template)
if template:
try:
parsed = yaml.safe_load(template)
except yaml.YAMLError:
parsed = None
if parsed and isinstance(parsed, dict) and "services" in parsed:
port_str = str(new_port)
port_exposed = False
for service_config in parsed["services"].values():
if (
isinstance(service_config, dict)
and "ports" in service_config
):
for port_mapping in service_config["ports"]:
if (
isinstance(port_mapping, str)
and port_str in port_mapping
):
port_exposed = True
break
elif (
isinstance(port_mapping, int)
and port_mapping == new_port
):
port_exposed = True
break
if port_exposed:
break
if not port_exposed:
parsed = validate_compose_yaml(template)
if not check_port_exposed(parsed, new_port):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Port {new_port} is not exposed in the compose template",
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
)
# Validate required variables for compose definitions
definition_type = update_data.get("definition_type", tool_type.definition_type)
if definition_type == "compose":
if "required_variables" in update_data and "compose_template" in update_data:
template = update_data["compose_template"]
for var in update_data["required_variables"]:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required variable '{var}' not found in compose template",
)
validate_required_variables(
update_data["compose_template"], update_data["required_variables"]
)
elif "required_variables" in update_data:
template = tool_type.compose_template
if template:
for var in update_data["required_variables"]:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required variable '{var}' not found in compose template",
)
validate_required_variables(template, update_data["required_variables"])
# When switching to manifest, clear legacy templates
if definition_type == "manifest":
if "manifest_id" in update_data:
tool_type.manifest_id = update_data["manifest_id"]
tool_type.compose_template = None
tool_type.dockerfile_template = None
for field, value in update_data.items():
setattr(tool_type, field, value)
@@ -260,7 +241,7 @@ async def update_tool_type(
)
async def validate_tool_type_template(
data: ToolTypeValidateRequest,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Validate a tool type template syntax.
@@ -273,6 +254,7 @@ async def validate_tool_type_template(
Returns:
Validation result with success status and any errors.
"""
await _get_user(session, user_id)
errors = []
@@ -281,15 +263,9 @@ async def validate_tool_type_template(
errors.append("Compose template is required")
else:
try:
parsed = yaml.safe_load(data.compose_template)
if not isinstance(parsed, dict):
errors.append("Compose template must be a YAML mapping")
elif "services" not in parsed:
errors.append("Compose template must contain 'services' key")
elif not parsed["services"]:
errors.append("Compose template must define at least one service")
except yaml.YAMLError as e:
errors.append(f"Invalid YAML: {e}")
validate_compose_yaml(data.compose_template)
except ValueError as e:
errors.append(str(e))
elif data.definition_type == "dockerfile":
if not data.dockerfile_template:
@@ -297,8 +273,11 @@ async def validate_tool_type_template(
elif not data.dockerfile_template.strip().startswith("FROM"):
errors.append("Dockerfile must start with a FROM instruction")
elif data.definition_type == "manifest":
pass # Manifest validation is handled separately
else:
errors.append("definition_type must be 'compose' or 'dockerfile'")
errors.append("definition_type must be 'compose', 'dockerfile', or 'manifest'")
return {
"valid": len(errors) == 0,
@@ -313,7 +292,7 @@ async def validate_tool_type_template(
)
async def validate_tool_type(
tool_type_id: uuid.UUID,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Validate a tool type's template syntax.
@@ -326,6 +305,7 @@ async def validate_tool_type(
Returns:
Validation result with success status and any errors.
"""
await _get_user(session, user_id)
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
raise HTTPException(
@@ -339,15 +319,9 @@ async def validate_tool_type(
errors.append("Compose template is empty")
else:
try:
parsed = yaml.safe_load(tool_type.compose_template)
if not isinstance(parsed, dict):
errors.append("Compose template must be a YAML mapping")
elif "services" not in parsed:
errors.append("Compose template must contain 'services' key")
elif not parsed["services"]:
errors.append("Compose template must define at least one service")
except yaml.YAMLError as e:
errors.append(f"Invalid YAML: {e}")
validate_compose_yaml(tool_type.compose_template)
except ValueError as e:
errors.append(str(e))
elif tool_type.definition_type == "dockerfile":
if not tool_type.dockerfile_template:
@@ -355,6 +329,10 @@ async def validate_tool_type(
elif not tool_type.dockerfile_template.strip().startswith("FROM"):
errors.append("Dockerfile must start with a FROM instruction")
elif tool_type.definition_type == "manifest":
if not tool_type.manifest_id:
errors.append("Manifest reference is missing")
return {
"valid": len(errors) == 0,
"errors": errors,
@@ -369,7 +347,7 @@ async def validate_tool_type(
)
async def delete_tool_type(
tool_type_id: uuid.UUID,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete a tool type.
@@ -382,6 +360,7 @@ async def delete_tool_type(
Returns:
None with 204 status code.
"""
user = await _get_user(session, user_id)
await _require_admin(user)
tool_type = await session.get(ToolType, tool_type_id)
@@ -390,11 +369,7 @@ async def delete_tool_type(
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
)
if tool_type.is_builtin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="cannot delete built-in tool types",
)
# Built-in tool types can now be deleted
await session.delete(tool_type)
await session.commit()
-286
View File
@@ -1,286 +0,0 @@
"""Tool instance API endpoints."""
import logging
import uuid
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user, get_db_session, get_owned_project
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.models.user import User
from src.schemas.tool_instance import CreateInstanceRequest
from src.services import instance_lifecycle as lifecycle
from src.services.docker import container as container_svc
from src.services.docker import tunnel as tunnel_svc
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/projects", tags=["tool-instances"])
async def _get_instance(session: AsyncSession, instance_id: uuid.UUID, repo_id: uuid.UUID) -> ToolInstance:
instance = await session.get(ToolInstance, instance_id)
if instance is None or instance.repository_id != repo_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="instance not found")
return instance
async def _get_repo(session: AsyncSession, repo_id: uuid.UUID, project_id: uuid.UUID) -> GitRepository:
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
# ── Endpoints ──────────────────────────────────────────────────────────────
@router.post("/{project_id}/repositories/{repo_id}/instances")
async def create_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
data: CreateInstanceRequest,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a new tool instance."""
repo = await _get_repo(session, repo_id, project_id)
tool_type = await session.get(ToolType, uuid.UUID(data.tool_type_id))
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
selected_profile = None
if data.config_profile_id:
from src.models.config_profile import ConfigProfile
selected_profile = await session.get(ConfigProfile, uuid.UUID(data.config_profile_id))
if selected_profile is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config profile not found")
if selected_profile.user_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="config profile does not belong to user")
instance = await lifecycle.create_new_instance(
session, project, repo, tool_type, user, data.display_name, selected_profile,
ssh_key_ids=data.ssh_key_ids or None,
)
return {
"id": str(instance.id),
"name": instance.name,
"display_name": instance.display_name,
"tool_type_id": str(instance.tool_type_id),
"status": instance.status,
"config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None,
"ssh_key_ids": instance.ssh_key_ids or [],
"created_at": instance.created_at.isoformat(),
}
@router.get("/{project_id}/repositories/{repo_id}/instances")
async def list_instances(
project_id: uuid.UUID,
repo_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List all tool instances for a repository."""
await _get_repo(session, repo_id, project_id)
from sqlalchemy import select
result = await session.execute(
select(ToolInstance)
.where(ToolInstance.repository_id == repo_id, ToolInstance.owner_id == user.id)
.order_by(ToolInstance.created_at.desc())
)
instances = []
for i in result.scalars().all():
tt = await session.get(ToolType, i.tool_type_id)
instances.append({
"id": str(i.id), "name": i.name, "display_name": i.display_name,
"tool_type_id": str(i.tool_type_id), "tool_type_name": tt.name if tt else "unknown",
"tool_type_interfaces": tt.interfaces if tt else [],
"status": i.status, "url": i.url, "port": i.port,
"config_profile_id": str(i.selected_profile_id) if i.selected_profile_id else None,
"created_at": i.created_at.isoformat(),
})
return {"instances": instances}
@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}")
async def get_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get a specific instance with real-time Docker status."""
from datetime import datetime
instance = await _get_instance(session, instance_id, repo_id)
if instance.container_id:
docker_status = container_svc.get_container_status(instance.container_id)
if docker_status == "running" and instance.status != "running":
instance.status = "running"
await session.commit()
elif docker_status == "exited" and instance.status == "running":
instance.status = "stopped"
instance.last_stopped_at = datetime.now()
await session.commit()
return {
"id": str(instance.id), "name": instance.name, "display_name": instance.display_name,
"tool_type_id": str(instance.tool_type_id), "status": instance.status,
"container_id": instance.container_id, "compose_path": instance.compose_path,
"url": instance.url, "port": instance.port,
"config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None,
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
"created_at": instance.created_at.isoformat(),
}
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/start")
async def start_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Start a tool instance."""
instance = await _get_instance(session, instance_id, repo_id)
return await lifecycle.start_existing_instance(session, instance, user, project_id)
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop")
async def stop_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Stop a running tool instance."""
instance = await _get_instance(session, instance_id, repo_id)
await lifecycle.stop_existing_instance(session, instance)
return {"status": instance.status}
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart")
async def restart_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Restart a tool instance."""
instance = await _get_instance(session, instance_id, repo_id)
return await lifecycle.restart_existing_instance(session, instance, user, project_id)
@router.delete("/{project_id}/repositories/{repo_id}/instances/{instance_id}")
async def delete_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete a tool instance."""
instance = await _get_instance(session, instance_id, repo_id)
await lifecycle.delete_existing_instance(session, instance)
@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs")
async def get_instance_logs(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
tail: int = 100,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get container logs for an instance."""
instance = await _get_instance(session, instance_id, repo_id)
if not instance.container_id:
return {"logs": "No container running"}
return {"logs": container_svc.get_container_logs(instance.container_id, tail)}
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/recreate-tunnel")
async def recreate_tunnel_endpoint(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Recreate the temporary tunnel for an instance."""
instance = await _get_instance(session, instance_id, repo_id)
if instance.status != "running":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="instance must be running")
tool_type = await session.get(ToolType, instance.tool_type_id)
instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080
try:
tunnel_info = tunnel_svc.recreate_tunnel(
container_name=instance.container_name or instance.name,
port=instance_port,
old_pid=instance.tunnel_id,
)
instance.tunnel_id = tunnel_info["pid"]
instance.public_url = tunnel_info["url"]
instance.url = tunnel_info["url"]
await session.commit()
return {"status": "healthy", "url": instance.url}
except Exception as exc:
logger.exception("Failed to recreate tunnel")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to recreate tunnel: {exc}")
@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}/health")
async def check_instance_tunnel_health(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Check tunnel health for an instance."""
instance = await _get_instance(session, instance_id, repo_id)
if not instance.url or instance.status != "running":
return {"healthy": False, "status_code": None, "error": "instance not running"}
return tunnel_svc.check_tunnel_health(instance.url)
@router.api_route(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
)
async def proxy_to_instance(
request: Request,
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
path: str = "",
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db_session),
) -> Response:
"""Proxy HTTP requests to a running tool instance."""
instance = await _get_instance(session, instance_id, repo_id)
if instance.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not authorized")
if instance.status != "running" or not instance.container_name:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="instance is not running")
target_url = f"http://{instance.container_name}:{instance.port}"
if path:
target_url += f"/{path}"
query = str(request.query_params)
if query:
target_url += f"?{query}"
headers = dict(request.headers)
headers.pop("host", None)
headers.pop("cookie", None)
try:
async with httpx.AsyncClient() as client:
body = await request.body()
response = await client.request(
method=request.method, url=target_url, headers=headers,
content=body, follow_redirects=False, timeout=30.0,
)
except Exception as exc:
logger.error("Proxy error: %s", exc)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"failed to reach instance: {exc}")
resp_headers = dict(response.headers)
for h in ["content-encoding", "transfer-encoding", "connection"]:
resp_headers.pop(h, None)
return Response(content=response.content, status_code=response.status_code, headers=resp_headers)
+7
View File
@@ -0,0 +1,7 @@
"""User API routers module."""
from src.api.user.auth import router as auth_router
from src.api.user.ssh_keys import router as ssh_keys_router
from src.api.user.users import router as users_router
__all__ = ["auth_router", "ssh_keys_router", "users_router"]
@@ -1,5 +1,5 @@
import base64
import uuid
from datetime import datetime
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import serialization
@@ -8,21 +8,26 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user, get_db_session
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.config import Settings
from src.models.ssh_key import SSHKey
from src.models.user import User
from src.schemas.ssh_key import SSHKeyCreate, SSHKeyResponse
from src.models import SSHKey
from src.schemas.project import (
SSHKeyCreate,
SSHKeyResponse,
SignPayloadRequest,
SignatureResponse,
VerifySignatureRequest,
VerifySignatureResponse,
)
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
def _get_fernet() -> Fernet:
"""Generate a valid Fernet key from the session secret."""
import base64
import hashlib
settings = Settings()
# Derive a 32-byte key from the session secret using SHA256
key_bytes = hashlib.sha256(settings.session_secret.encode()).digest()
@@ -63,7 +68,7 @@ def generate_ssh_key_pair() -> tuple[str, str]:
)
async def create_ssh_key(
data: SSHKeyCreate,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> SSHKey:
"""Create a new SSH key pair.
@@ -76,6 +81,7 @@ async def create_ssh_key(
Returns:
The newly created SSH key with public key exposed.
"""
user = await _get_user(session, user_id)
private_key, public_key = generate_ssh_key_pair()
fernet = _get_fernet()
@@ -100,7 +106,7 @@ async def create_ssh_key(
description="List all SSH keys for the authenticated user.",
)
async def list_ssh_keys(
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[SSHKey]:
"""List all SSH keys for the authenticated user.
@@ -112,6 +118,7 @@ async def list_ssh_keys(
Returns:
List of SSH keys owned by the user.
"""
user = await _get_user(session, user_id)
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
return list(result.scalars().all())
@@ -124,7 +131,7 @@ async def list_ssh_keys(
)
async def delete_ssh_key(
key_id: uuid.UUID,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete an SSH key.
@@ -137,9 +144,93 @@ async def delete_ssh_key(
Returns:
None with 204 status code.
"""
user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
await session.delete(ssh_key)
await session.commit()
@router.post(
"/{key_id}/sign",
response_model=SignatureResponse,
summary="Sign payload",
description="Sign a payload using the SSH private key.",
)
async def sign_payload(
key_id: uuid.UUID,
data: SignPayloadRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> SignatureResponse:
"""Sign a payload with an SSH key.
Args:
key_id: UUID of the SSH key to use for signing.
data: Sign request containing the payload string.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Base64-encoded Ed25519 signature.
"""
user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
fernet = _get_fernet()
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
private_key = serialization.load_ssh_private_key(
private_key_pem.encode(), password=None
)
signature = private_key.sign(data.payload.encode())
return SignatureResponse(signature=base64.b64encode(signature).decode())
@router.post(
"/{key_id}/verify",
response_model=VerifySignatureResponse,
summary="Verify signature",
description="Verify a signature against a payload using the SSH public key.",
)
async def verify_signature(
key_id: uuid.UUID,
data: VerifySignatureRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> VerifySignatureResponse:
"""Verify a signature with an SSH key's public key.
Args:
key_id: UUID of the SSH key to use for verification.
data: Verify request containing payload and base64-encoded signature.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Whether the signature is valid.
"""
user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
try:
signature = base64.b64decode(data.signature)
public_key.verify(signature, data.payload.encode())
return VerifySignatureResponse(valid=True)
except Exception:
return VerifySignatureResponse(valid=False)
@@ -2,13 +2,10 @@ import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user, get_db_session
from src.models.tool_instance import ToolInstance
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models.user import User
from src.schemas.tool_instance import SessionItemResponse, SessionListResponse
from src.schemas.user import UserProfileResponse, UserProfileUpdate
router = APIRouter(prefix="/users", tags=["users"])
@@ -26,7 +23,7 @@ MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
description="Retrieve the profile of the currently authenticated user.",
)
async def get_profile(
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
"""Get the current user's profile.
@@ -38,7 +35,7 @@ async def get_profile(
Returns:
The user's profile information.
"""
return user
return await _get_user(session, user_id)
@router.put(
@@ -49,7 +46,7 @@ async def get_profile(
)
async def update_profile(
data: UserProfileUpdate,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
"""Update the current user's profile.
@@ -62,6 +59,7 @@ async def update_profile(
Returns:
The updated user profile.
"""
user = await _get_user(session, user_id)
if data.name is not None:
if len(data.name.strip()) == 0:
@@ -90,7 +88,7 @@ async def update_profile(
)
async def upload_avatar(
file: UploadFile,
user: User = Depends(get_current_user),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
"""Upload a profile avatar image.
@@ -103,6 +101,7 @@ async def upload_avatar(
Returns:
The updated user profile with new avatar URL.
"""
user = await _get_user(session, user_id)
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
@@ -137,41 +136,3 @@ async def upload_avatar(
await session.commit()
await session.refresh(user)
return user
@router.get(
"/me/sessions",
response_model=SessionListResponse,
summary="Get current user sessions",
description="Retrieve all tool instances (sessions) for the authenticated user.",
)
async def get_user_sessions(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db_session),
) -> SessionListResponse:
"""Return all tool instances for the current user with related names."""
result = await session.execute(
select(ToolInstance)
.where(ToolInstance.owner_id == user.id)
.order_by(ToolInstance.created_at.desc())
)
instances = result.scalars().all()
sessions = [
SessionItemResponse(
id=str(inst.id),
display_name=inst.display_name,
tool_type_name=inst.tool_type.display_name if inst.tool_type else "Unknown",
tool_icon=None,
tool_type_interfaces=inst.tool_type.interfaces if inst.tool_type else [],
repository_name=inst.repository.name if inst.repository else "Unknown",
repository_id=str(inst.repository_id),
project_name=inst.project.name if inst.project else "Unknown",
project_id=str(inst.project_id),
status=inst.status,
url=inst.url,
)
for inst in instances
]
return SessionListResponse(sessions=sessions)
+17
View File
@@ -0,0 +1,17 @@
"""Workspace API routers module."""
from src.api.workspace.workspace_files import router as workspace_files_router
from src.api.workspace.workspace_git import router as workspace_git_router
from src.api.workspace.workspace_instances import router as workspace_instances_router
from src.api.workspace.workspaces import (
all_workspaces_router,
router as workspaces_router,
)
__all__ = [
"all_workspaces_router",
"workspace_files_router",
"workspace_git_router",
"workspace_instances_router",
"workspaces_router",
]
@@ -6,8 +6,8 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.workspace import Workspace
from src.services.file_service import FileService
from src.models import Workspace
from src.services.shared.file_service import FileService
router = APIRouter(prefix="/workspaces/{workspace_id}/files")
@@ -103,7 +103,7 @@ async def write_file(
raise HTTPException(status_code=400, detail=str(exc)) from exc
if commit_message:
from src.services.git_operations import GitOperations
from src.services.git.git_operations import GitOperations
git = GitOperations(workspace)
try:
@@ -6,8 +6,8 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.workspace import Workspace
from src.services.git_operations import GitOperations
from src.models import Workspace
from src.services.git.git_operations import GitOperations
router = APIRouter(prefix="/workspaces/{workspace_id}/git")
@@ -7,8 +7,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_instance import ToolInstance
from src.models.workspace import Workspace
from src.models import ToolInstance
from src.models import Workspace
router = APIRouter(prefix="/workspaces/{workspace_id}/instances")
@@ -9,10 +9,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.git_repository import GitRepository
from src.models.tool_instance import ToolInstance
from src.models.workspace import Workspace
from src.services.workspace_manager import WorkspaceHasInstancesError, WorkspaceManager
from src.models import GitRepository
from src.models import ToolInstance
from src.models import Workspace
from src.services.shared.workspace_manager import WorkspaceHasInstancesError, WorkspaceManager
logger = logging.getLogger(__name__)
+19 -9
View File
@@ -50,17 +50,25 @@ async def get_current_user(
return user
async def get_owned_project(
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def _get_owned_project(
project_id: uuid.UUID,
user: User = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db_session),
) -> Project:
user_id: uuid.UUID,
session: AsyncSession,
) -> "Project":
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project (injected from path parameter).
user: The currently authenticated user.
db_session: Database session.
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
@@ -68,9 +76,11 @@ async def get_owned_project(
Raises:
HTTPException: 404 if project not found, 403 if user is not the owner.
"""
project = await db_session.get(Project, project_id)
from src.models.project import Project
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
if project.owner_id != user.id:
if project.owner_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
return project
+1 -1
View File
@@ -10,7 +10,7 @@ from collections.abc import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from src.services.correlation import get_correlation_id
from src.services.shared.correlation import get_correlation_id
logger = logging.getLogger(__name__)
+59 -14
View File
@@ -6,20 +6,34 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from src.api.auth import router as auth_router
from src.api.dashboard import router as dashboard_router
from src.api.git_repositories import router as git_repositories_router
from src.api.health import router as health_router
from src.api.projects import router as projects_router
from src.api.ssh_keys import router as ssh_keys_router
from src.api.terminal import router as terminal_router
from src.api.instance_proxy import router as instance_proxy_router
from src.api.config_profiles import router as config_profiles_router
from src.api.tool_instances import router as tool_instances_router
from src.api.tool_types import router as tool_types_router
from src.api.user_config import router as user_config_router
from src.api.users import router as users_router
from src.api.config import config_profiles_router, user_config_router
from src.api.project import git_repositories_router, projects_router
from src.api.system import (
dashboard_router,
events_router,
health_router,
instance_proxy_router,
notifications_router,
terminal_router,
)
from src.api.tool import (
sessions_router,
tool_definitions_router,
tool_instances_router,
tool_types_router,
)
from src.api.user import auth_router, ssh_keys_router, users_router
from src.api.workspace import (
all_workspaces_router,
workspace_files_router,
workspace_git_router,
workspace_instances_router,
workspaces_router,
)
from src.config import Settings
from src.models import Notification # noqa: F401 Alembic model discovery
from src.models import TerminalSessionModel # noqa: F401 Alembic model discovery
from src.database import init_database
from src.logging_config import (
ExceptionLoggingMiddleware,
@@ -27,6 +41,8 @@ from src.logging_config import (
configure_logging,
)
from src.seeds.builtin_tool_types import seed_builtin_tool_types
from src.services.instance import InstanceEventBus, HealthMonitor
from src.services.shared import CorrelationIdMiddleware
# Configure logging early
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
@@ -51,6 +67,7 @@ app.add_middleware(
allow_headers=["*"],
)
app.add_middleware(CorrelationIdMiddleware)
app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(ExceptionLoggingMiddleware)
@@ -100,6 +117,11 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
)
# Global services
_event_bus = InstanceEventBus()
_health_monitor = HealthMonitor(_event_bus)
@app.on_event("startup")
async def on_startup():
logger.info("Starting up Headquarter API...")
@@ -112,11 +134,25 @@ async def on_startup():
sys.exit(1)
# Seed built-in data
# Start background health monitor
_health_monitor.start()
logger.info("Health monitor started")
# Seed built-in tool types
await seed_builtin_tool_types()
logger.info("Built-in tool types seeded")
logger.info("Startup complete.")
@app.on_event("shutdown")
async def on_shutdown():
logger.info("Shutting down Headquarter API...")
_health_monitor.stop()
logger.info("Health monitor stopped")
logger.info("Shutdown complete.")
app.include_router(health_router)
app.include_router(auth_router)
app.include_router(dashboard_router)
@@ -126,8 +162,17 @@ app.include_router(ssh_keys_router)
app.include_router(git_repositories_router)
app.include_router(user_config_router)
app.include_router(tool_types_router)
app.include_router(tool_definitions_router)
app.include_router(config_profiles_router)
app.include_router(tool_instances_router)
app.include_router(sessions_router)
app.include_router(instance_proxy_router)
app.include_router(terminal_router)
app.include_router(events_router)
app.include_router(notifications_router)
app.include_router(all_workspaces_router)
app.include_router(workspaces_router)
app.include_router(workspace_files_router)
app.include_router(workspace_git_router)
app.include_router(workspace_instances_router)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
+21 -12
View File
@@ -1,25 +1,34 @@
from src.models.base import Base
from src.models.config_include import ConfigInclude
from src.models.config_mount import ConfigMount
from src.models.config_profile import ConfigProfile
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.models.user import User
from src.models.user_config import UserConfig
from src.models.config.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.project.git_repository import GitRepository
from src.models.project.project import Project
from src.models.project.workspace import Workspace
from src.models.system.health_check import HealthCheck
from src.models.system.instance_event import InstanceEvent
from src.models.system.notification import Notification
from src.models.system.terminal_session import TerminalSessionModel
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest
from src.models.tool.tool_instance import ToolInstance
from src.models.tool.tool_type import ToolType
from src.models.user.ssh_key import SSHKey
from src.models.user.user import User
from src.models.user.user_config import UserConfig
__all__ = [
"Base",
"ConfigInclude",
"ConfigMount",
"ConfigProfile",
"ConfigProfileInclude",
"GitRepository",
"HealthCheck",
"InstanceEvent",
"Notification",
"Project",
"SSHKey",
"TerminalSessionModel",
"ToolDefinitionManifest",
"ToolInstance",
"ToolType",
"User",
"UserConfig",
"Workspace",
]
+5
View File
@@ -0,0 +1,5 @@
"""Config models module."""
from src.models.config.config_profile import ConfigProfile, ConfigProfileInclude
__all__ = ["ConfigProfile", "ConfigProfileInclude"]
@@ -0,0 +1,88 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import (
Boolean,
ForeignKey,
JSON,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.project import Project
from src.models import ToolType
from src.models.user import User
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_profiles"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
project_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
)
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
)
env_vars: Mapped[dict] = mapped_column(
JSON, default=dict, nullable=False
) # {"VAR_NAME": "value", ...}
runtime_hints: Mapped[dict] = mapped_column(
JSON, default=dict, nullable=False
) # {"start_command": "...", "working_dir": "...", ...}
mounts: Mapped[list] = mapped_column(
JSON, default=list, nullable=False
) # [{"target": "/path", "mode": "rw", "files": {"rel/path": "content"}}, ...]
files: Mapped[dict] = mapped_column(
JSON, default=dict, nullable=False
) # {"rel/path": "content", ...}
git_mounts: Mapped[list] = mapped_column(
JSON, default=list, nullable=False
) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
user: Mapped["User"] = relationship()
project: Mapped["Project | None"] = relationship()
tool_type: Mapped["ToolType | None"] = relationship()
includes: Mapped[list["ConfigProfileInclude"]] = relationship(
"ConfigProfileInclude",
foreign_keys="ConfigProfileInclude.profile_id",
order_by="ConfigProfileInclude.order_index",
cascade="all, delete-orphan",
)
class ConfigProfileInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_profile_includes"
profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
included_profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[profile_id],
back_populates="includes",
)
included_profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[included_profile_id],
)
-36
View File
@@ -1,36 +0,0 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, UniqueConstraint
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.config_profile import ConfigProfile
class ConfigInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_includes"
__table_args__ = (
UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
)
profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
included_profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[profile_id],
back_populates="includes",
)
included_profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[included_profile_id],
)
-31
View File
@@ -1,31 +0,0 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, JSON, String
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.config_profile import ConfigProfile
class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_mounts"
profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
target_path: Mapped[str] = mapped_column(String(1024), nullable=False)
mode: Mapped[str] = mapped_column(String(10), nullable=False, default="rw")
files: Mapped[dict[str, str] | None] = mapped_column(
JSON, default=dict, nullable=True
)
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[profile_id],
back_populates="mounts",
)
-59
View File
@@ -1,59 +0,0 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.config_include import ConfigInclude
from src.models.config_mount import ConfigMount
from src.models.project import Project
from src.models.tool_type import ToolType
from src.models.user import User
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_profiles"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
project_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
)
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
environment_variables: Mapped[dict[str, str] | None] = mapped_column(
JSON, default=dict, nullable=True
)
start_command: Mapped[str | None] = mapped_column(Text, nullable=True)
working_directory: Mapped[str | None] = mapped_column(Text, nullable=True)
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
is_default: Mapped[bool] = mapped_column(default=False, nullable=False)
user: Mapped["User"] = relationship()
project: Mapped["Project | None"] = relationship()
tool_type: Mapped["ToolType | None"] = relationship()
includes: Mapped[list["ConfigInclude"]] = relationship(
"ConfigInclude",
primaryjoin="ConfigProfile.id == ConfigInclude.profile_id",
back_populates="profile",
cascade="all, delete-orphan",
order_by="ConfigInclude.order_index",
)
mounts: Mapped[list["ConfigMount"]] = relationship(
"ConfigMount",
primaryjoin="ConfigProfile.id == ConfigMount.profile_id",
back_populates="profile",
cascade="all, delete-orphan",
order_by="ConfigMount.order_index",
)
+7
View File
@@ -0,0 +1,7 @@
"""Project models module."""
from src.models.project.git_repository import GitRepository
from src.models.project.project import Project
from src.models.project.workspace import Workspace
__all__ = ["GitRepository", "Project", "Workspace"]
@@ -10,7 +10,7 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models import SSHKey
from src.models.user import User
@@ -8,8 +8,8 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.git_repository import GitRepository
from src.models.ssh_key import SSHKey
from src.models import GitRepository
from src.models import SSHKey
from src.models.user import User
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin
if TYPE_CHECKING:
from src.models.git_repository import GitRepository
from src.models import GitRepository
from src.models.user import User
+8
View File
@@ -0,0 +1,8 @@
"""System models module."""
from src.models.system.health_check import HealthCheck
from src.models.system.instance_event import InstanceEvent
from src.models.system.notification import Notification
from src.models.system.terminal_session import TerminalSessionModel
__all__ = ["HealthCheck", "InstanceEvent", "Notification", "TerminalSessionModel"]
+7
View File
@@ -0,0 +1,7 @@
"""Tool models module."""
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest
from src.models.tool.tool_instance import ToolInstance
from src.models.tool.tool_type import ToolType
__all__ = ["ToolDefinitionManifest", "ToolInstance", "ToolType"]
@@ -9,11 +9,12 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.config_profile import ConfigProfile
from src.models.git_repository import GitRepository
from src.models import ConfigProfile
from src.models import GitRepository
from src.models.project import Project
from src.models.tool_type import ToolType
from src.models import ToolType
from src.models.user import User
from src.models import Workspace
class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
@@ -33,45 +34,40 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
owner_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("users.id"), nullable=False
)
status: Mapped[str] = mapped_column(
String(50), nullable=False, default="pending"
)
container_id: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
container_name: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
compose_path: Mapped[str | None] = mapped_column(
String(1024), nullable=True
)
url: Mapped[str | None] = mapped_column(
String(1024), nullable=True
)
public_url: Mapped[str | None] = mapped_column(
String(1024), nullable=True
)
tunnel_id: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
port: Mapped[int | None] = mapped_column(
Integer, nullable=True
)
status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
container_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
container_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
compose_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
public_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
tunnel_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
last_started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_stopped_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
selected_profile_id: Mapped[uuid.UUID | None] = mapped_column(
manifest_compiled_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
image_tag: Mapped[str | None] = mapped_column(String(256), nullable=True)
probe_result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
clone_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="mount")
branch: Mapped[str | None] = mapped_column(
String(255), nullable=True, default="main"
)
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
)
ssh_key_ids: Mapped[list[str] | None] = mapped_column(
JSON, nullable=True
ssh_key_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
workspace_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("workspaces.id", ondelete="SET NULL"), nullable=True
)
tool_type: Mapped["ToolType"] = relationship()
workspace: Mapped["Workspace | None"] = relationship()
repository: Mapped["GitRepository"] = relationship()
project: Mapped["Project"] = relationship()
owner: Mapped["User"] = relationship()
selected_profile: Mapped["ConfigProfile | None"] = relationship()
selected_config_profile: Mapped["ConfigProfile | None"] = relationship()
@@ -1,12 +1,12 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text, Uuid as UUID
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from src.models.tool_definition_manifest import ToolDefinitionManifest
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest
if TYPE_CHECKING:
from src.models.user import User
@@ -52,21 +52,3 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
foreign_keys=[manifest_id],
)
created_by: Mapped["User | None"] = relationship()
@property
def interfaces(self) -> list[str]:
"""Backward-compatible API view for the single interface type."""
return [self.interface_type]
@interfaces.setter
def interfaces(self, value: list[str] | str) -> None:
"""Accept legacy interface lists and store the first interface type."""
if isinstance(value, str):
self.interface_type = value
return
self.interface_type = value[0] if value else "web"
@property
def is_builtin(self) -> bool:
"""Built-in tools are seeded system tools without a creating user."""
return self.created_by_id is None
+7
View File
@@ -0,0 +1,7 @@
"""User models module."""
from src.models.user.ssh_key import SSHKey
from src.models.user.user import User
from src.models.user.user_config import UserConfig
__all__ = ["SSHKey", "User", "UserConfig"]
@@ -7,8 +7,8 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.user_config import UserConfig
from src.models import SSHKey
from src.models import UserConfig
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
@@ -1,8 +1,8 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey
from sqlalchemy import JSON, Uuid as UUID
from sqlalchemy import ForeignKey, JSON
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
@@ -14,15 +14,22 @@ if TYPE_CHECKING:
class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "user_configs"
user_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False, unique=True)
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("users.id"), nullable=False, unique=True
)
config: Mapped[dict[str, object]] = mapped_column(
JSON, default=dict, nullable=False
)
user: Mapped["User"] = relationship(back_populates="user_config")
@property
def default_profile_id(self) -> uuid.UUID | None:
"""Return the legacy global default profile ID from config JSON."""
profile_id = self.config.get("default_profile_id")
return uuid.UUID(profile_id) if profile_id else None
if isinstance(profile_id, str):
return uuid.UUID(profile_id)
return None
@default_profile_id.setter
def default_profile_id(self, value: uuid.UUID | None) -> None:
@@ -33,7 +40,11 @@ class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
@property
def default_profiles(self) -> dict[str, str]:
return self.config.get("default_profiles", {})
"""Return per-tool-type default profile IDs from config JSON."""
value = self.config.get("default_profiles", {})
if isinstance(value, dict):
return {str(k): str(v) for k, v in value.items()}
return {}
@default_profiles.setter
def default_profiles(self, value: dict[str, str]) -> None:
-1
View File
@@ -1 +0,0 @@
"""Pydantic request/response schemas."""
+27
View File
@@ -0,0 +1,27 @@
"""Config schemas module."""
from src.schemas.config.config_profile import (
ConfigProfileCreate,
ConfigProfileIncludeUpdate,
ConfigProfileResponse,
ConfigProfileUpdate,
DefaultProfilesUpdate,
GitMountItem,
GitMountMapping,
MountItem,
ValidateGitUrlRequest,
ValidateGitUrlResponse,
)
__all__ = [
"ConfigProfileCreate",
"ConfigProfileIncludeUpdate",
"ConfigProfileResponse",
"ConfigProfileUpdate",
"DefaultProfilesUpdate",
"GitMountItem",
"GitMountMapping",
"MountItem",
"ValidateGitUrlRequest",
"ValidateGitUrlResponse",
]
@@ -0,0 +1,280 @@
"""Config profile request/response schemas."""
import uuid
from pydantic import BaseModel, Field, field_validator, model_validator
from src.api.shared_validators import validate_env_vars as _validate_env_vars
def _validate_uuid(v: str | None) -> str | None:
if v is None:
return v
try:
uuid.UUID(v)
except ValueError as exc:
raise ValueError(f"Invalid UUID: {v}") from exc
return v
class GitMountMapping(BaseModel):
source_path: str = Field(
description="Path within repository (supports glob patterns)"
)
target_path: str = Field(description="Absolute path inside container")
@field_validator("source_path")
@classmethod
def validate_source_path(cls, v: str) -> str:
if v.startswith("/"):
raise ValueError("source_path must be relative (no leading /)")
if ".." in v:
raise ValueError("source_path cannot contain path traversal (..)")
return v
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str) -> str:
if ".." in v:
raise ValueError("target_path cannot contain path traversal (..)")
return v
class GitMountItem(BaseModel):
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
source_path: str | None = Field(
default=None, description="Path within repository (legacy single mapping)"
)
target_path: str | None = Field(
default=None,
description="Absolute path inside container (legacy single mapping)",
)
branch: str | None = Field(default=None, description="Optional branch or tag name")
mappings: list[GitMountMapping] | None = Field(
default=None, description="Multiple source/target mappings from the same repo"
)
@field_validator("remote_url")
@classmethod
def validate_remote_url(cls, v: str) -> str:
if not v.startswith(("http://", "https://", "git@", "ssh://")):
raise ValueError(
"remote_url must be a valid git URL (https://, git@, or ssh://)"
)
return v
@field_validator("source_path")
@classmethod
def validate_source_path(cls, v: str | None) -> str | None:
if v is None:
return v
if v.startswith("/"):
raise ValueError("source_path must be relative (no leading /)")
if ".." in v:
raise ValueError("source_path cannot contain path traversal (..)")
return v
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str | None) -> str | None:
if v is None:
return v
if ".." in v:
raise ValueError("target_path cannot contain path traversal (..)")
return v
@model_validator(mode="after")
def check_mappings_or_legacy(self):
has_legacy = self.source_path is not None and self.target_path is not None
has_mappings = self.mappings is not None and len(self.mappings) > 0
if not has_legacy and not has_mappings:
raise ValueError(
"Git mount must have either 'mappings' (non-empty array) or both 'source_path' and 'target_path'"
)
return self
class MountItem(BaseModel):
target: str = Field(description="Absolute mount target path")
mode: str = Field(default="rw", description="Mount mode: ro or rw")
files: dict = Field(
default_factory=dict, description="Files as {relative_path: content}"
)
@field_validator("target")
@classmethod
def validate_target(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("Mount target must be absolute (start with /)")
return v
@field_validator("mode")
@classmethod
def validate_mode(cls, v: str) -> str:
if v not in ("ro", "rw"):
raise ValueError("Mount mode must be 'ro' or 'rw'")
return v
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
for path in v:
if ".." in path or not path:
raise ValueError(f"Invalid file path: {path}")
if path.startswith("/"):
raise ValueError(
f"Mount file paths must be relative (got: {path}). "
f"The mount target defines the absolute container path."
)
return v
class ConfigProfileCreate(BaseModel):
name: str = Field(description="Profile name (unique per user)")
description: str | None = Field(default=None, description="Optional description")
project_id: str | None = Field(default=None, description="Optional project ID")
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
env_vars: dict = Field(default_factory=dict, description="Environment variables")
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
mounts: list[MountItem] = Field(
default_factory=list, description="Mount definitions"
)
files: dict = Field(
default_factory=dict, description="Files as {relative_path: content}"
)
git_mounts: list[GitMountItem] = Field(
default_factory=list, description="Git repository mounts"
)
is_default: bool = Field(
default=False, description="Whether this is the default profile for its scope"
)
@field_validator("project_id", "tool_type_id")
@classmethod
def validate_uuids(cls, v: str | None) -> str | None:
return _validate_uuid(v)
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
for path in v:
if ".." in path or not path:
raise ValueError(f"Invalid file path: {path}")
if path.startswith("/"):
raise ValueError(
f"File paths must be relative (got: {path}). "
f"Use Mounts for absolute container paths."
)
return v
@field_validator("env_vars")
@classmethod
def validate_env_vars(cls, v: dict) -> dict:
result = _validate_env_vars(v)
if result is None:
raise ValueError("env_vars must be a JSON object")
return result
@field_validator("runtime_hints")
@classmethod
def validate_runtime_hints(cls, v: dict) -> dict:
if not isinstance(v, dict):
raise ValueError("runtime_hints must be a JSON object")
return v
@field_validator("mounts")
@classmethod
def validate_mounts(cls, v: list) -> list:
if not isinstance(v, list):
raise ValueError("mounts must be a JSON array")
return v
class ConfigProfileUpdate(BaseModel):
name: str | None = Field(default=None, description="Profile name")
description: str | None = Field(default=None, description="Optional description")
project_id: str | None = Field(default=None, description="Optional project ID")
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
env_vars: dict | None = Field(default=None, description="Environment variables")
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
mounts: list[MountItem] | None = Field(
default=None, description="Mount definitions"
)
files: dict | None = Field(
default=None, description="Files as {relative_path: content}"
)
git_mounts: list[GitMountItem] | None = Field(
default=None, description="Git repository mounts"
)
is_default: bool | None = Field(
default=None, description="Whether this is the default profile"
)
@field_validator("project_id", "tool_type_id")
@classmethod
def validate_uuids(cls, v: str | None) -> str | None:
return _validate_uuid(v)
@field_validator("files")
@classmethod
def validate_files(cls, v: dict | None) -> dict | None:
if v is None:
return v
for path in v:
if ".." in path or path.startswith("/") or not path:
raise ValueError(f"Invalid file path: {path}")
return v
class ConfigProfileIncludeUpdate(BaseModel):
includes: list[str] = Field(description="Ordered list of included profile IDs")
@field_validator("includes")
@classmethod
def validate_includes(cls, v: list) -> list:
for item in v:
try:
uuid.UUID(item)
except ValueError as exc:
raise ValueError(f"Invalid UUID in includes: {item}") from exc
return v
class ConfigProfileResponse(BaseModel):
id: str
user_id: str
name: str
description: str | None
project_id: str | None
tool_type_id: str | None
env_vars: dict
runtime_hints: dict
mounts: list
files: dict
git_mounts: list
is_default: bool
includes: list[dict]
created_at: str
updated_at: str
class DefaultProfilesUpdate(BaseModel):
default_profiles: dict[str, str] = Field(
description="Mapping of tool_type_id -> profile_id for default profiles"
)
class ValidateGitUrlRequest(BaseModel):
url: str = Field(description="Git remote URL to validate")
ssh_key_id: str | None = Field(
default=None, description="Optional SSH key ID for private repos"
)
class ValidateGitUrlResponse(BaseModel):
valid: bool
suggested_url: str | None = None
branches: list[str] | None = None
default_branch: str | None = None
error: str | None = None
error_code: str | None = None
-131
View File
@@ -1,131 +0,0 @@
"""Config profile request/response schemas."""
from typing import Any
from pydantic import BaseModel, Field, field_validator
MAX_MOUNT_PATH_LENGTH = 1024
class ConfigProfileCreate(BaseModel):
name: str = Field(description="Profile name (unique per user)")
description: str | None = Field(default=None, description="Optional description")
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Profile name cannot be empty")
if len(v) > 255:
raise ValueError("Profile name must be 255 characters or less")
return v
class ConfigProfileUpdate(BaseModel):
name: str | None = Field(default=None, description="Profile name")
description: str | None = Field(default=None, description="Optional description")
@field_validator("name")
@classmethod
def validate_name(cls, v: str | None) -> str | None:
if v is None:
return v
v = v.strip()
if not v:
raise ValueError("Profile name cannot be empty")
if len(v) > 255:
raise ValueError("Profile name must be 255 characters or less")
return v
class ConfigProfileResponse(BaseModel):
id: str
user_id: str
name: str
description: str | None
created_at: str
updated_at: str
class ConfigProfileDetailResponse(ConfigProfileResponse):
includes: list[dict[str, Any]]
mounts: list[dict[str, Any]]
class ConfigIncludeCreate(BaseModel):
included_profile_id: str = Field(description="UUID of the profile to include")
order_index: int = Field(default=0, description="Order index for include resolution")
class ConfigIncludeUpdate(BaseModel):
order_index: int = Field(description="Order index for include resolution")
class ConfigIncludeResponse(BaseModel):
id: str
profile_id: str
included_profile_id: str
included_profile_name: str | None
order_index: int
created_at: str
updated_at: str
class ConfigMountCreate(BaseModel):
target_path: str = Field(description="Absolute target path in container")
mode: str = Field(default="rw", description="Mount mode (rw or ro)")
files: dict[str, str] | None = Field(
default=None, description="Files as {path: content}"
)
order_index: int = Field(default=0, description="Order index for mount resolution")
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("Target path must be absolute (start with /)")
if ".." in v:
raise ValueError("Target path cannot contain parent directory references (..)")
if len(v) > MAX_MOUNT_PATH_LENGTH:
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
return v
class ConfigMountUpdate(BaseModel):
target_path: str | None = Field(default=None, description="Absolute target path in container")
mode: str | None = Field(default=None, description="Mount mode (rw or ro)")
files: dict[str, str] | None = Field(
default=None, description="Files as {path: content}"
)
order_index: int | None = Field(default=None, description="Order index for mount resolution")
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str | None) -> str | None:
if v is None:
return v
if not v.startswith("/"):
raise ValueError("Target path must be absolute (start with /)")
if ".." in v:
raise ValueError("Target path cannot contain parent directory references (..)")
if len(v) > MAX_MOUNT_PATH_LENGTH:
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
return v
class ConfigMountResponse(BaseModel):
id: str
profile_id: str
target_path: str
mode: str
files: dict[str, str] | None
order_index: int
created_at: str
updated_at: str
class DefaultProfilesUpdate(BaseModel):
default_profiles: dict[str, str] = Field(
description="Mapping of tool_type_id to profile_id"
)
-129
View File
@@ -1,129 +0,0 @@
"""Git repository request/response schemas."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class GitRepositoryCreate(BaseModel):
name: str
remote_url: str | None = None
force_original_url: bool = False
class URLParseRequest(BaseModel):
url: str
class URLParseResponse(BaseModel):
original_url: str
base_url: str | None
is_valid_clone_url: bool
needs_parsing: bool
host: str | None
message: str
error_code: str | None
class GitRepositoryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
path: str
project_id: uuid.UUID
owner_id: uuid.UUID
is_mirror: bool
remote_url: str | None
last_push: datetime | None
created_at: datetime
updated_at: datetime
class FileListResponse(BaseModel):
path: str
branch: str
entries: list[dict]
class FileContentResponse(BaseModel):
path: str
branch: str
content: str
size: int
encoding: str
language: str | None
is_binary: bool
last_commit: dict | None
class BranchesResponse(BaseModel):
branches: list[dict]
default_branch: str
class FileUpdateRequest(BaseModel):
path: str
branch: str
content: str
commit_message: str
class FileUpdateResponse(BaseModel):
commit_hash: str
message: str
branch: str
class StatusResponse(BaseModel):
branch: str
modified: list[str]
added: list[str]
deleted: list[str]
untracked: list[str]
renamed: list[str]
ahead: int
behind: int
class BranchCreateRequest(BaseModel):
name: str
base_branch: str = "HEAD"
class CheckoutRequest(BaseModel):
branch: str
class CommitRequest(BaseModel):
message: str
files: list[str] | None = None
class CommitResponse(BaseModel):
commit_hash: str
message: str
class FetchResponse(BaseModel):
message: str
class PullResponse(BaseModel):
message: str
class PushResponse(BaseModel):
message: str
class MergeRequest(BaseModel):
source_branch: str
target_branch: str | None = None
message: str | None = None
class MergeResponse(BaseModel):
commit_hash: str
message: str
+41
View File
@@ -0,0 +1,41 @@
"""Project schemas module."""
from src.schemas.project.git_repository import (
GitRepositoryCreate,
GitRepositoryResponse,
UpdateSSHKeyRequest,
URLParseRequest,
URLParseResponse,
)
from src.schemas.project.project import (
ProjectCreate,
ProjectResponse,
ProjectUpdate,
SetDefaultSSHKeyRequest,
)
from src.schemas.project.ssh_key import (
SSHKeyCreate,
SSHKeyResponse,
SignPayloadRequest,
SignatureResponse,
VerifySignatureRequest,
VerifySignatureResponse,
)
__all__ = [
"GitRepositoryCreate",
"GitRepositoryResponse",
"ProjectCreate",
"ProjectResponse",
"ProjectUpdate",
"SSHKeyCreate",
"SSHKeyResponse",
"SetDefaultSSHKeyRequest",
"SignPayloadRequest",
"SignatureResponse",
"URLParseRequest",
"URLParseResponse",
"UpdateSSHKeyRequest",
"VerifySignatureRequest",
"VerifySignatureResponse",
]
@@ -0,0 +1,47 @@
"""Git repository request/response schemas."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class GitRepositoryCreate(BaseModel):
name: str
remote_url: str | None = None
force_original_url: bool = False
ssh_key_id: str | None = None
class URLParseRequest(BaseModel):
url: str
class URLParseResponse(BaseModel):
original_url: str
base_url: str | None
is_valid_clone_url: bool
needs_parsing: bool
host: str | None
message: str
error_code: str | None
class GitRepositoryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
path: str
project_id: uuid.UUID | None
owner_id: uuid.UUID
is_mirror: bool
remote_url: str | None
last_push: datetime | None
ssh_key_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
class UpdateSSHKeyRequest(BaseModel):
ssh_key_id: str | None = None
@@ -1,6 +1,8 @@
"""Project request/response schemas."""
from pydantic import BaseModel
import uuid
from pydantic import BaseModel, ConfigDict
class ProjectCreate(BaseModel):
@@ -14,12 +16,14 @@ class ProjectUpdate(BaseModel):
class ProjectResponse(BaseModel):
id: str
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
created_at: str
updated_at: str
owner_id: uuid.UUID
default_ssh_key_id: uuid.UUID | None
class SetDefaultSSHKeyRequest(BaseModel):
ssh_key_id: str
ssh_key_id: uuid.UUID
+36
View File
@@ -0,0 +1,36 @@
"""SSH key request/response schemas."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class SSHKeyCreate(BaseModel):
name: str
class SSHKeyResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
public_key: str
created_at: datetime
class SignPayloadRequest(BaseModel):
payload: str
class SignatureResponse(BaseModel):
signature: str
class VerifySignatureRequest(BaseModel):
payload: str
signature: str
class VerifySignatureResponse(BaseModel):
valid: bool
-16
View File
@@ -1,16 +0,0 @@
"""SSH key request/response schemas."""
from pydantic import BaseModel
class SSHKeyCreate(BaseModel):
name: str
public_key: str
class SSHKeyResponse(BaseModel):
id: str
name: str
public_key: str
fingerprint: str
created_at: str
+17
View File
@@ -0,0 +1,17 @@
"""System schemas module."""
from src.schemas.system.health import (
DatabaseHealth,
DatabaseHealthResponse,
DiskHealth,
HealthChecks,
HealthResponse,
)
__all__ = [
"DatabaseHealth",
"DatabaseHealthResponse",
"DiskHealth",
"HealthChecks",
"HealthResponse",
]
+18
View File
@@ -0,0 +1,18 @@
"""Tool schemas module."""
from src.schemas.tool.tool_instance import CreateInstanceRequest, StartInstanceRequest
from src.schemas.tool.tool_type import (
ToolTypeCreate,
ToolTypeResponse,
ToolTypeUpdate,
ToolTypeValidateRequest,
)
__all__ = [
"CreateInstanceRequest",
"StartInstanceRequest",
"ToolTypeCreate",
"ToolTypeResponse",
"ToolTypeUpdate",
"ToolTypeValidateRequest",
]
@@ -0,0 +1,45 @@
"""Tool instance request/response schemas."""
from pydantic import BaseModel, Field
class CreateInstanceRequest(BaseModel):
"""Request body for creating a tool instance."""
model_config = {"extra": "ignore"}
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
display_name: str | None = Field(
default=None, description="Optional display name for the instance"
)
workspace_id: str | None = Field(
default=None, description="UUID of workspace to mount (replaces clone_mode)"
)
clone_mode: str = Field(
default="mount", description="Repository access mode: 'mount' or 'clone'"
)
branch: str | None = Field(
default="main", description="Branch to clone (when clone_mode='clone')"
)
new_branch: str | None = Field(
default=None, description="Create a new local branch after cloning"
)
config_profile_id: str | None = Field(
default=None, description="Optional config profile ID for launch"
)
ssh_key_ids: list[str] = Field(
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
)
class StartInstanceRequest(BaseModel):
"""Request body for starting a tool instance."""
model_config = {"extra": "ignore"}
config_profile_id: str | None = Field(
default=None, description="Config profile ID to apply, or null for none"
)
ssh_key_ids: list[str] = Field(
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
)
@@ -1,31 +1,42 @@
"""Tool type request/response schemas."""
import uuid
from datetime import datetime
import yaml
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from src.api.tool.tool_types_validation import (
check_port_exposed,
validate_compose_yaml,
validate_required_variables,
)
class ToolTypeCreate(BaseModel):
name: str
display_name: str
description: str | None = None
default_port: int
default_port: int = 0
definition_type: str = "compose"
manifest_id: uuid.UUID | None = None
compose_template: str | None = None
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
startup_command: str | None = None
required_variables: list[str] = []
category: str = "other"
interfaces: list[str] = ["web"]
interface_type: str = "web"
requires_port: bool = True
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str) -> str:
if v not in ("compose", "dockerfile"):
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
if v not in ("compose", "dockerfile", "manifest"):
raise ValueError(
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
)
return v
@field_validator("compose_template")
@@ -34,18 +45,13 @@ class ToolTypeCreate(BaseModel):
data = info.data
if data.get("definition_type") != "compose":
return v
if v is None:
raise ValueError("compose_template is required when definition_type is 'compose'")
try:
parsed = yaml.safe_load(v)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}")
if not isinstance(parsed, dict):
raise ValueError("Compose template must be a YAML mapping")
if "services" not in parsed:
raise ValueError("Compose template must contain 'services' key")
if not parsed["services"]:
raise ValueError("Compose template must define at least one service")
if v is None or not v.strip():
raise ValueError(
"compose_template is required when definition_type is 'compose'"
)
validate_compose_yaml(v)
return v
@field_validator("dockerfile_template")
@@ -54,43 +60,33 @@ class ToolTypeCreate(BaseModel):
data = info.data
if data.get("definition_type") != "dockerfile":
return v
if v is None:
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
if v is None or not v.strip():
raise ValueError(
"dockerfile_template is required when definition_type is 'dockerfile'"
)
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
@field_validator("interface_type")
@classmethod
def validate_interface_type(cls, v: str) -> str:
if v not in ("web", "terminal"):
raise ValueError("interface_type must be 'web' or 'terminal'")
return v
@field_validator("default_port")
@classmethod
def validate_default_port(cls, v: int, info) -> int:
data = info.data
requires_port = data.get("requires_port", True)
if not requires_port:
return v
if v <= 0 or v > 65535:
raise ValueError("Port must be between 1 and 65535")
data = info.data
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
try:
parsed = yaml.safe_load(template)
except yaml.YAMLError:
return v
port_str = str(v)
port_exposed = False
if isinstance(parsed, dict) and "services" in parsed:
for service_config in parsed["services"].values():
if isinstance(service_config, dict) and "ports" in service_config:
for port_mapping in service_config["ports"]:
if isinstance(port_mapping, str) and port_str in port_mapping:
port_exposed = True
break
elif isinstance(port_mapping, int) and port_mapping == v:
port_exposed = True
break
if port_exposed:
break
if not port_exposed:
raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.")
return v
@field_validator("required_variables")
@@ -98,24 +94,55 @@ class ToolTypeCreate(BaseModel):
def validate_required_variables(cls, v: list[str], info) -> list[str]:
if not v:
return v
data = info.data
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
for var in v:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise ValueError(f"Required variable '{var}' not found in compose template")
validate_required_variables(template, v)
return v
@model_validator(mode="after")
def validate_templates(self) -> "ToolTypeCreate":
if self.definition_type == "dockerfile" and self.dockerfile_template is None:
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
if self.definition_type == "compose" and self.compose_template is None:
raise ValueError("compose_template is required when definition_type is 'compose'")
if self.definition_type == "manifest":
if self.manifest_id is None:
raise ValueError(
"manifest_id is required when definition_type is 'manifest'"
)
return self
if self.definition_type == "dockerfile" and (
self.dockerfile_template is None or not self.dockerfile_template.strip()
):
raise ValueError(
"dockerfile_template is required when definition_type is 'dockerfile'"
)
if self.definition_type == "compose" and (
self.compose_template is None or not self.compose_template.strip()
):
raise ValueError(
"compose_template is required when definition_type is 'compose'"
)
if (
self.requires_port
and self.definition_type == "compose"
and self.compose_template
):
try:
parsed = validate_compose_yaml(self.compose_template)
except ValueError:
return self
if not check_port_exposed(parsed, self.default_port):
raise ValueError(
f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section."
)
return self
@@ -124,21 +151,35 @@ class ToolTypeUpdate(BaseModel):
description: str | None = None
default_port: int | None = None
definition_type: str | None = None
manifest_id: uuid.UUID | None = None
compose_template: str | None = None
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
startup_command: str | None = None
required_variables: list[str] | None = None
category: str | None = None
interfaces: list[str] | None = None
interface_type: str | None = None
requires_port: bool | None = None
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in ("compose", "dockerfile"):
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
if v not in ("compose", "dockerfile", "manifest"):
raise ValueError(
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
)
return v
@field_validator("interface_type")
@classmethod
def validate_interface_type(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in ("web", "terminal"):
raise ValueError("interface_type must be 'web' or 'terminal'")
return v
@field_validator("compose_template")
@@ -146,20 +187,7 @@ class ToolTypeUpdate(BaseModel):
def validate_compose_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
data = info.data
definition_type = data.get("definition_type")
if definition_type and definition_type != "compose":
return v
try:
parsed = yaml.safe_load(v)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}")
if not isinstance(parsed, dict):
raise ValueError("Compose template must be a YAML mapping")
if "services" not in parsed:
raise ValueError("Compose template must contain 'services' key")
if not parsed["services"]:
raise ValueError("Compose template must define at least one service")
validate_compose_yaml(v)
return v
@field_validator("dockerfile_template")
@@ -167,10 +195,6 @@ class ToolTypeUpdate(BaseModel):
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
data = info.data
definition_type = data.get("definition_type")
if definition_type and definition_type != "dockerfile":
return v
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
@@ -184,15 +208,17 @@ class ToolTypeResponse(BaseModel):
display_name: str
description: str | None
category: str
interfaces: list[str]
interface_type: str
requires_port: bool
default_port: int
definition_type: str
manifest_id: uuid.UUID | None
compose_template: str | None
dockerfile_template: str | None
build_context: dict | None
readiness_probe: dict | None
startup_command: str | None
required_variables: list[str]
is_builtin: bool
created_by_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
-44
View File
@@ -1,44 +0,0 @@
"""Tool instance request/response schemas."""
from pydantic import BaseModel, Field
class CreateInstanceRequest(BaseModel):
"""Request body for creating a tool instance."""
model_config = {"extra": "ignore"}
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
display_name: str | None = Field(
default=None, description="Optional display name for the instance"
)
config_profile_id: str | None = Field(
default=None, description="Optional config profile ID to apply to the instance"
)
ssh_key_ids: list[str] = Field(
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
)
class SessionItemResponse(BaseModel):
"""Lightweight session summary for sidebar and dashboard."""
model_config = {"extra": "ignore"}
id: str = Field(description="Session (tool instance) ID")
display_name: str = Field(description="Display name of the session")
tool_type_name: str = Field(description="Name of the tool type")
tool_icon: str | None = Field(default=None, description="Icon URL for the tool type")
tool_type_interfaces: list[str] = Field(default_factory=list, description="Supported interfaces")
repository_name: str = Field(description="Name of the repository")
repository_id: str = Field(description="Repository ID")
project_name: str = Field(description="Name of the project")
project_id: str = Field(description="Project ID")
status: str = Field(description="Current status")
url: str | None = Field(default=None, description="Access URL")
class SessionListResponse(BaseModel):
"""Response wrapping a list of session summaries."""
sessions: list[SessionItemResponse]
+11
View File
@@ -0,0 +1,11 @@
"""User schemas module."""
from src.schemas.user.user import UserProfileResponse, UserProfileUpdate
from src.schemas.user.user_config import UserConfigResponse, UserConfigUpdate
__all__ = [
"UserConfigResponse",
"UserConfigUpdate",
"UserProfileResponse",
"UserProfileUpdate",
]
@@ -1,4 +1,4 @@
"""User request/response schemas."""
"""User response schemas."""
import uuid
@@ -1,4 +1,4 @@
"""User config request/response schemas."""
"""User config response schemas."""
from pydantic import BaseModel, ConfigDict
@@ -11,6 +11,8 @@ class UserConfigResponse(BaseModel):
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
notification_mute_categories: list[str] | None = None
notification_toast_level: str | None = None
class UserConfigUpdate(BaseModel):
@@ -19,3 +21,5 @@ class UserConfigUpdate(BaseModel):
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
notification_mute_categories: list[str] | None = None
notification_toast_level: str | None = None
+1
View File
@@ -0,0 +1 @@
"""Database seeding utilities."""
+12 -6
View File
@@ -1,17 +1,17 @@
"""Seed built-in tool types into the database."""
import logging
from sqlalchemy import select
from sqlalchemy import select, text
from src.database import SessionLocal
from src.models.tool_type import ToolType
from src.models import ToolType
logger = logging.getLogger(__name__)
async def _table_exists(session, table_name: str) -> bool:
"""Check if a table exists in the database."""
from sqlalchemy import text
try:
result = await session.execute(
text(
@@ -31,6 +31,11 @@ async def _table_exists(session, table_name: str) -> bool:
async def seed_builtin_tool_types():
"""Create or update built-in tool types in the database.
Built-in tool types have no creator (created_by_id=None) and provide
out-of-the-box tools for users without requiring manual tool creation.
"""
async with SessionLocal() as session:
# Check if tool_types table exists before attempting to seed
if not await _table_exists(session, "tool_types"):
@@ -142,7 +147,8 @@ volumes:
definition_type="compose",
compose_template=tool_data["compose_template"],
required_variables=tool_data["required_variables"],
default_port=tool_data["default_port"],
default_port=tool_data.get("default_port", 0),
created_by_id=None,
)
session.add(tool_type)
logger.info("Created built-in tool type: %s", tool_data["name"])
@@ -155,7 +161,7 @@ volumes:
existing.definition_type = "compose"
existing.compose_template = tool_data["compose_template"]
existing.required_variables = tool_data["required_variables"]
existing.default_port = tool_data["default_port"]
existing.default_port = tool_data.get("default_port", 0)
logger.info("Updated built-in tool type: %s", tool_data["name"])
await session.commit()
+25
View File
@@ -0,0 +1,25 @@
"""Config profile services module."""
from src.services.config.config_profile_resolver import (
ConfigProfileCycleError,
ConfigProfileNotFoundError,
ResolvedMount,
ResolvedProfile,
apply_resolved_profile,
check_include_cycle,
expand_container_path,
resolve_profile,
resolved_profile_to_dict,
)
__all__ = [
"ConfigProfileCycleError",
"ConfigProfileNotFoundError",
"ResolvedMount",
"ResolvedProfile",
"apply_resolved_profile",
"check_include_cycle",
"expand_container_path",
"resolve_profile",
"resolved_profile_to_dict",
]
@@ -13,7 +13,7 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models import ConfigProfile, ConfigProfileInclude
logger = logging.getLogger(__name__)
-299
View File
@@ -1,299 +0,0 @@
"""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()]}
+35 -23
View File
@@ -1,43 +1,55 @@
"""Docker services for container and tunnel management."""
"""Docker services package for container and compose operations."""
from .compose import (
ensure_instance_directory,
from src.services.docker.compose import (
execute_compose_command,
render_compose_template,
sort_volumes_by_specificity,
write_compose_file,
)
from src.services.docker.config_staging import (
ensure_instance_directory,
write_config_files,
write_env_file,
)
from .config_staging import write_config_files
from .container import (
from src.services.docker.container import (
connect_container_to_network,
find_free_port,
get_backend_network_name,
get_container_id,
get_container_ip_on_network,
get_container_logs,
get_container_name,
get_container_status,
is_container_on_network,
wait_for_container_running,
)
from .tunnel import (
from src.services.docker.tunnel import (
check_tunnel_health,
recreate_tunnel,
start_cloudflared_tunnel,
stop_cloudflared_tunnel,
start_tunnel,
stop_tunnel,
)
__all__ = [
"render_compose_template",
"ensure_instance_directory",
"write_compose_file",
"write_env_file",
"execute_compose_command",
"write_config_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",
"connect_container_to_network",
"ensure_instance_directory",
"execute_compose_command",
"find_free_port",
"get_backend_network_name",
"get_container_id",
"get_container_ip_on_network",
"get_container_logs",
"get_container_name",
"get_container_status",
"is_container_on_network",
"recreate_tunnel",
"render_compose_template",
"sort_volumes_by_specificity",
"start_tunnel",
"stop_tunnel",
"wait_for_container_running",
"write_compose_file",
"write_config_files",
"write_env_file",
]
+34 -151
View File
@@ -1,133 +1,50 @@
"""Docker Compose file generation and command execution."""
"""Docker Compose file generation and manipulation."""
import re
import logging
import subprocess
import uuid
from collections import Counter
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
logger = logging.getLogger(__name__)
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("-")
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.
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}"
Volume format: source:target or source:target:type
Args:
volumes: List of Docker volume mount strings.
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
Returns:
Sorted list with parent paths before child paths.
"""
compose_file = Path(compose_path)
content = compose_file.read_text()
compose_data = yaml.safe_load(content)
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("/")
if not compose_data or "services" not in compose_data:
return
# 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)
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
# 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:
@@ -147,24 +64,6 @@ def render_compose_template(template: str, variables: dict[str, Any]) -> str:
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.
@@ -180,22 +79,6 @@ def write_compose_file(instance_dir: str, content: str) -> str:
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]:
@@ -218,7 +101,7 @@ def execute_compose_command(
cmd.extend(["--env-file", env_file])
if action == "up":
cmd.extend(["up", "-d"])
cmd.extend(["up", "-d", "--force-recreate"])
elif action == "down":
cmd.extend(["down", "-v"])
elif action in ("start", "stop", "restart"):
+40 -1
View File
@@ -1,7 +1,46 @@
"""Config file staging for Docker instances."""
"""Staging configuration files into instance directories."""
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
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_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.
+215 -20
View File
@@ -1,59 +1,123 @@
"""Docker container lifecycle and query operations."""
"""Docker container runtime queries and network management."""
import socket
import logging
import subprocess
import time
from typing import Any
logger = logging.getLogger(__name__)
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 service name in compose
instance_name: The expected container name.
Returns:
Container ID or None if not found
Container ID or None if not found.
"""
expected = instance_name.lower()
# Fast path: exact match via docker inspect
result = subprocess.run(
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
["docker", "inspect", "-f", "{{.Id}}", expected],
capture_output=True,
text=True,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().split("\n")[0]
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 service name in compose
instance_name: The exact container name (case-insensitive for Docker).
Returns:
Container name or None if not found
Container name or None if not found.
"""
result = subprocess.run(
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
["docker", "inspect", "-f", "{{.Name}}", instance_name.lower()],
capture_output=True,
text=True,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().split("\n")[0]
return result.stdout.strip().lstrip("/")
return None
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
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 (default: backend)
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,
@@ -62,24 +126,153 @@ def connect_container_to_network(container_name: str, network_name: str = "backe
return result.returncode == 0
def get_container_status(container_id: str) -> str:
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:
Container status string (running, exited, etc.)
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}}", container_id],
[
"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 result.stdout.strip()
return "unknown"
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:
@@ -113,6 +306,8 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
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:
+231 -96
View File
@@ -1,146 +1,281 @@
"""Cloudflare tunnel management for Docker instances."""
"""Cloudflare tunnel management using cloudflared Docker containers.
Each tunnel runs as a Docker container on the same 'backend' network as the API.
cloudflared connects to the tool container by its Docker Compose service name
(e.g. http://code-server-headquarter-34837cd3:8443).
"""
import logging
import os
import re
import signal
import subprocess
import time
from typing import Any
from src.services.docker.container import get_backend_network_name
logger = logging.getLogger(__name__)
TUNNEL_IMAGE = "cloudflare/cloudflared:latest"
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.
def _tunnel_container_name(instance_name: str) -> str:
return f"tunnel-{instance_name.lower()}"
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}"],
def _ensure_image() -> None:
"""Pull cloudflared image if not already present."""
result = subprocess.run(
["docker", "images", "-q", TUNNEL_IMAGE],
capture_output=True,
text=True,
)
if not result.stdout.strip():
logger.info("Pulling %s ...", TUNNEL_IMAGE)
pull = subprocess.run(
["docker", "pull", TUNNEL_IMAGE],
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)
if pull.returncode != 0:
logger.warning("Failed to pull %s: %s", TUNNEL_IMAGE, pull.stderr)
# 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,
def _cleanup_stale_tunnel(tunnel_name: str) -> None:
"""Remove any existing tunnel container with this name."""
subprocess.run(
["docker", "stop", "-t", "3", tunnel_name],
capture_output=True,
text=True,
)
subprocess.run(
["docker", "rm", "-f", tunnel_name],
capture_output=True,
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 _get_tunnel_logs(tunnel_name: str) -> tuple[str, str]:
"""Get stdout and stderr logs from a container."""
result = subprocess.run(
["docker", "logs", tunnel_name],
capture_output=True,
text=True,
)
return result.stdout, result.stderr
def stop_cloudflared_tunnel(pid: str) -> None:
"""Stop a cloudflared tunnel process.
def _get_tunnel_exit_code(tunnel_name: str) -> int | None:
"""Get exit code of a container if it has exited."""
result = subprocess.run(
["docker", "inspect", "-f", "{{.State.ExitCode}}", tunnel_name],
capture_output=True,
text=True,
)
if result.returncode == 0:
try:
return int(result.stdout.strip())
except ValueError:
pass
return None
def start_tunnel(
instance_name: str,
container_port: int,
timeout: int = 30,
target_url: str | None = None,
) -> dict[str, str]:
"""Start a temporary Cloudflare tunnel for an instance.
Args:
pid: Process ID of the cloudflared tunnel
instance_name: The tool instance name (used for tunnel naming).
container_port: The port the tool container listens on internally.
timeout: Seconds to wait for the tunnel URL.
target_url: Optional explicit URL to proxy to. If omitted, derives
http://{instance_name.lower()}:{container_port}.
Returns:
Dict with 'url' and 'container_name'.
"""
try:
os.kill(int(pid), signal.SIGTERM)
except ProcessLookupError:
pass # Already stopped
_ensure_image()
tunnel_name = _tunnel_container_name(instance_name)
_cleanup_stale_tunnel(tunnel_name)
# Target the tool container by name on the backend network
if target_url is None:
target_url = f"http://{instance_name.lower()}:{container_port}"
cmd = [
"docker",
"run",
"-d",
"--network",
get_backend_network_name(),
"--name",
tunnel_name,
TUNNEL_IMAGE,
"tunnel",
"--no-autoupdate",
"--url",
target_url,
]
logger.debug("Running: %s", " ".join(cmd))
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError(
f"Failed to start tunnel container {tunnel_name}: {proc.stderr}"
)
container_id = proc.stdout.strip()
logger.debug("Tunnel container started: %s", container_id)
# Wait for URL to appear in logs
# Exclude api.trycloudflare.com which is the Cloudflare API endpoint,
# not a tunnel URL. Real tunnel URLs have random subdomains (10+ chars).
url_pattern = re.compile(r"https://(?!api\.)[a-z0-9-]{10,}\.trycloudflare\.com")
start_time = __import__("time").time()
url: str | None = None
combined_logs = ""
while __import__("time").time() - start_time < timeout:
stdout, stderr = _get_tunnel_logs(tunnel_name)
combined_logs = stdout + "\n" + stderr
match = url_pattern.search(combined_logs)
if match:
url = match.group(0)
break
# Check if container exited early
exit_code = _get_tunnel_exit_code(tunnel_name)
if exit_code is not None and exit_code != 0:
_cleanup_stale_tunnel(tunnel_name)
raise RuntimeError(
f"Tunnel container {tunnel_name} exited with code {exit_code}. "
f"Logs:\n{combined_logs[-3000:]}"
)
__import__("time").sleep(0.5)
if not url:
stdout, stderr = _get_tunnel_logs(tunnel_name)
combined_logs = stdout + "\n" + stderr
exit_code = _get_tunnel_exit_code(tunnel_name)
_cleanup_stale_tunnel(tunnel_name)
raise RuntimeError(
f"Tunnel {tunnel_name} did not produce a URL within {timeout}s. "
f"Exit code: {exit_code}. Logs:\n{combined_logs[-3000:]}"
)
# Wait a moment for Cloudflare DNS edge to propagate the new tunnel subdomain
__import__("time").sleep(2)
logger.info(
"Tunnel %s started for %s%s (%s)",
tunnel_name,
instance_name,
target_url,
url,
)
return {"url": url, "container_name": tunnel_name}
def stop_tunnel(instance_name: str) -> None:
"""Stop and remove the tunnel container for an instance."""
tunnel_name = _tunnel_container_name(instance_name)
_cleanup_stale_tunnel(tunnel_name)
logger.debug("Stopped and removed tunnel container %s", tunnel_name)
def recreate_tunnel(
container_name: str, port: int, old_pid: str | None = None
instance_name: str, container_port: int, target_url: str | None = None
) -> dict[str, str]:
"""Recreate a temporary Cloudflare tunnel.
Stops the old tunnel (if pid provided) and starts a new one.
"""Recreate a tunnel for an instance.
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
instance_name: The tool instance name.
container_port: The port the tool container listens on internally.
target_url: Optional explicit origin URL. If omitted, derives
http://{instance_name.lower()}:{container_port}.
"""
if old_pid:
stop_cloudflared_tunnel(old_pid)
return start_cloudflared_tunnel(container_name, port)
stop_tunnel(instance_name)
return start_tunnel(instance_name, container_port, target_url=target_url)
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)
Dict with 'tunnel_status', 'status_code', 'healthy', 'error'.
"""
try:
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"--max-time", str(timeout), url],
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--max-time",
str(timeout),
url,
],
capture_output=True,
text=True,
timeout=timeout + 5,
)
status_code = int(result.stdout.strip())
if 200 <= status_code < 400:
return {
"tunnel_status": "healthy",
"status_code": status_code,
"healthy": True,
"error": None,
}
if status_code in (502, 503, 504):
return {
"tunnel_status": "error_response",
"status_code": status_code,
"healthy": False,
"error": f"Application returned HTTP {status_code}",
}
return {
"healthy": 200 <= status_code < 400,
"tunnel_status": "error_response",
"status_code": status_code,
}
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
return {
"healthy": False,
"status_code": None,
"error": str(e),
"error": f"HTTP {status_code}",
}
except subprocess.TimeoutExpired:
return {
"tunnel_status": "unreachable",
"status_code": None,
"healthy": False,
"error": "Tunnel request timed out",
}
except (ValueError, Exception) as exc:
error_str = str(exc).lower()
if any(
err in error_str
for err in [
"connection refused",
"econnrefused",
"could not resolve",
"nodename",
]
):
return {
"tunnel_status": "unreachable",
"status_code": None,
"healthy": False,
"error": f"Tunnel unreachable: {exc}",
}
return {
"tunnel_status": "unreachable",
"status_code": None,
"healthy": False,
"error": str(exc),
}
+19 -1
View File
@@ -1 +1,19 @@
"""Git services package."""
"""Git services module."""
from src.services.git.clone import (
check_dirty_state,
clone_repository,
remove_clone_directory,
)
from src.services.git.git_operations import Commit, GitOperations, GitStatus
from src.services.git.git_service import GitService
__all__ = [
"check_dirty_state",
"clone_repository",
"remove_clone_directory",
"Commit",
"GitOperations",
"GitStatus",
"GitService",
]
-196
View File
@@ -1,196 +0,0 @@
"""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))
-150
View File
@@ -1,150 +0,0 @@
"""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))
@@ -4,7 +4,7 @@ import asyncio
import logging
from dataclasses import dataclass
from src.models.workspace import Workspace
from src.models import Workspace
logger = logging.getLogger(__name__)
-211
View File
@@ -1,211 +0,0 @@
"""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())

Some files were not shown because too many files have changed in this diff Show More