Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Blank c051929f8c fix: use host bind mount for repos so tool instances can access workspace files
Replace named Docker volume (repo_data) with bind mount (/data/repos) in both
development and production compose files. The named volume trapped repo files
inside the API container; tool instances started via Docker socket on the host
could not see them, causing /workspace to mount as an empty directory.

Also fix 6 pre-existing test failures in test_tool_instances_legacy.py caused
by get_container_id/get_container_name moving to docker.py and new helpers
(_ensure_web_bind_address, _ensure_container_name_in_compose) being added.

- docker-compose.yml: repo_data:/data/repos -> /data/repos:/data/repos
- docker-compose.traefik.yml: same change + remove repo_data volume decl
- tests: update patch targets and add missing mock parameters

Quality gates: pytest test_tool_instances_legacy.py (10 passed)
2026-06-02 12:47:14 +02:00
227 changed files with 3801 additions and 15636 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"fingerprint": "c36b11ec5edebc02aa51b1113a7a11dc2559e812" "fingerprint": "fdea8a74bb4c7449c01c4bd61646c895b10ede78"
} }
+2 -1
View File
@@ -2,7 +2,7 @@
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. --> <!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
Last updated: 2026-06-02 Last updated: 2026-05-28
## Sources scanned ## Sources scanned
@@ -21,6 +21,7 @@ Last updated: 2026-06-02
| Skill | Trigger / description | Scope | Path | | Skill | Trigger / description | Scope | Path |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `auto-commit` | Use when you are making multiple edits or completing significant work in a git repository to automatically create commits | user | `/home/alex/.config/opencode/skills/auto-commit/SKILL.md` | | `auto-commit` | Use when you are making multiple edits or completing significant work in a git repository to automatically create commits | user | `/home/alex/.config/opencode/skills/auto-commit/SKILL.md` |
| `openspec` | Use OpenSpec as the source of truth for planning, implementation, verification, and archive discipline. | user | `/home/alex/.config/opencode/skills/openspec/SKILL.md` |
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-apply-change/SKILL.md` | | `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-apply-change/SKILL.md` |
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-archive-change/SKILL.md` | | `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-archive-change/SKILL.md` |
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-explore/SKILL.md` | | `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-explore/SKILL.md` |
-1
View File
@@ -75,7 +75,6 @@ Do not:
* Introduce new dependencies without clear justification. * Introduce new dependencies without clear justification.
* Treat existing code as more authoritative than OpenSpec for intended behavior. * Treat existing code as more authoritative than OpenSpec for intended behavior.
* Decide product behavior silently when the spec is unclear. * Decide product behavior silently when the spec is unclear.
* Run `docker compose` commands (build, up, down, etc.) without explicit user approval and proper isolation (e.g., feature branches, separate worktrees, or staged rollouts). Docker Compose operations are deployment-level changes that can affect running services, shared volumes, and network state. Always ask first.
If scope must change, propose an OpenSpec update first. If scope must change, propose an OpenSpec update first.
-42
View File
@@ -1,42 +0,0 @@
# 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/
+2 -2
View File
@@ -50,8 +50,8 @@ ENV PATH=/root/.local/bin:$PATH
# Copy application code # Copy application code
COPY --chown=appuser:appgroup . . COPY --chown=appuser:appgroup . .
# Create directories for repo, instance, and workspace storage # Create directories for repo and instance storage
RUN mkdir -p /data/repos /data/instances /data/working-copies && chown -R appuser:appgroup /data RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data
# Copy wait-for-db script # Copy wait-for-db script
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
@@ -1,204 +0,0 @@
"""add config profiles, includes, mounts, and tool instance profile selection
Revision ID: 0013_add_config_profiles
Revises: 0012_default_port_req
Create Date: 2026-05-24 12:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0013_add_config_profiles"
down_revision: str | None = "0012_default_port_req"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _table_exists(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _column_exists(table_name: str, column_name: str) -> bool:
if not _table_exists(table_name):
return False
return column_name in {
column["name"] for column in sa.inspect(op.get_bind()).get_columns(table_name)
}
def _index_exists(table_name: str, index_name: str) -> bool:
if not _table_exists(table_name):
return False
return index_name in {
index["name"] for index in sa.inspect(op.get_bind()).get_indexes(table_name)
}
def _foreign_key_exists(
table_name: str,
constrained_columns: list[str],
referred_table: str,
) -> bool:
if not _table_exists(table_name):
return False
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name):
if (
foreign_key.get("constrained_columns") == constrained_columns
and foreign_key.get("referred_table") == referred_table
):
return True
return False
def upgrade() -> None:
# Earlier branches may already have created config_profiles. Keep this
# migration defensive so databases can converge onto the current graph.
if not _table_exists("config_profiles"):
op.create_table(
"config_profiles",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"user_id", "name", name="uq_config_profiles_user_name"
),
)
if not _index_exists("config_profiles", "idx_config_profiles_user"):
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
if not _table_exists("config_includes"):
op.create_table(
"config_includes",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column(
"included_profile_id", postgresql.UUID(as_uuid=True), nullable=False
),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["profile_id"], ["config_profiles.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["included_profile_id"],
["config_profiles.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"profile_id", "included_profile_id", name="uq_config_includes_pair"
),
)
if not _index_exists("config_includes", "idx_config_includes_profile"):
op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"])
if not _index_exists("config_includes", "idx_config_includes_included"):
op.create_index(
"idx_config_includes_included", "config_includes", ["included_profile_id"]
)
if not _table_exists("config_mounts"):
op.create_table(
"config_mounts",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("mount_path", sa.String(length=1024), nullable=False),
sa.Column("content", sa.Text(), nullable=True),
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["profile_id"], ["config_profiles.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["source_profile_id"], ["config_profiles.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
)
if not _index_exists("config_mounts", "idx_config_mounts_profile"):
op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"])
if not _column_exists("tool_instances", "selected_profile_id"):
op.add_column(
"tool_instances",
sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
)
if not _foreign_key_exists(
"tool_instances", ["selected_profile_id"], "config_profiles"
):
op.create_foreign_key(
"fk_tool_instances_selected_profile",
"tool_instances",
"config_profiles",
["selected_profile_id"],
["id"],
ondelete="SET NULL",
)
if not _index_exists("tool_instances", "idx_tool_instances_selected_profile"):
op.create_index(
"idx_tool_instances_selected_profile",
"tool_instances",
["selected_profile_id"],
)
def downgrade() -> None:
# Remove selected_profile_id from tool_instances
op.drop_index("idx_tool_instances_selected_profile", table_name="tool_instances")
op.drop_constraint(
"fk_tool_instances_selected_profile", "tool_instances", type_="foreignkey"
)
op.drop_column("tool_instances", "selected_profile_id")
# Drop config_mounts
op.drop_index("idx_config_mounts_profile", table_name="config_mounts")
op.drop_table("config_mounts")
# Drop config_includes
op.drop_index("idx_config_includes_included", table_name="config_includes")
op.drop_index("idx_config_includes_profile", table_name="config_includes")
op.drop_table("config_includes")
# Drop config_profiles
op.drop_index("idx_config_profiles_user", table_name="config_profiles")
op.drop_table("config_profiles")
@@ -1,180 +0,0 @@
"""add profile resolver fields to config profiles and mounts
Revision ID: 0014_add_profile_resolver_fields
Revises: 0013_add_config_profiles
Create Date: 2026-05-24 14:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0014_add_profile_resolver_fields"
down_revision: str | None = "0013_add_config_profiles"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _table_exists(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _column_exists(table_name: str, column_name: str) -> bool:
if not _table_exists(table_name):
return False
return column_name in {
column["name"] for column in sa.inspect(op.get_bind()).get_columns(table_name)
}
def _index_exists(table_name: str, index_name: str) -> bool:
if not _table_exists(table_name):
return False
return index_name in {
index["name"] for index in sa.inspect(op.get_bind()).get_indexes(table_name)
}
def _foreign_key_exists(
table_name: str,
constrained_columns: list[str],
referred_table: str,
) -> bool:
if not _table_exists(table_name):
return False
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name):
if (
foreign_key.get("constrained_columns") == constrained_columns
and foreign_key.get("referred_table") == referred_table
):
return True
return False
def _foreign_key_names_for_column(table_name: str, column_name: str) -> list[str]:
if not _table_exists(table_name):
return []
names: list[str] = []
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name):
if column_name in foreign_key.get("constrained_columns", []):
name = foreign_key.get("name")
if name:
names.append(name)
return names
def upgrade() -> None:
if not _column_exists("config_profiles", "project_id"):
op.add_column(
"config_profiles",
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
)
if not _column_exists("config_profiles", "tool_type_id"):
op.add_column(
"config_profiles",
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True),
)
if not _column_exists("config_profiles", "environment_variables"):
op.add_column(
"config_profiles",
sa.Column("environment_variables", sa.JSON(), nullable=True),
)
if not _column_exists("config_profiles", "start_command"):
op.add_column(
"config_profiles",
sa.Column("start_command", sa.Text(), nullable=True),
)
if not _column_exists("config_profiles", "working_directory"):
op.add_column(
"config_profiles",
sa.Column("working_directory", sa.Text(), nullable=True),
)
if not _column_exists("config_profiles", "port"):
op.add_column("config_profiles", sa.Column("port", sa.Integer(), nullable=True))
if not _column_exists("config_profiles", "is_default"):
op.add_column(
"config_profiles",
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
)
if not _foreign_key_exists("config_profiles", ["project_id"], "projects"):
op.create_foreign_key(
"fk_config_profiles_project",
"config_profiles",
"projects",
["project_id"],
["id"],
ondelete="CASCADE",
)
if not _foreign_key_exists("config_profiles", ["tool_type_id"], "tool_types"):
op.create_foreign_key(
"fk_config_profiles_tool_type",
"config_profiles",
"tool_types",
["tool_type_id"],
["id"],
ondelete="CASCADE",
)
if not _index_exists("config_profiles", "idx_config_profiles_project"):
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
if not _index_exists("config_profiles", "idx_config_profiles_tool_type"):
op.create_index(
"idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"]
)
if _column_exists("config_mounts", "mount_path") and not _column_exists(
"config_mounts", "target_path"
):
op.alter_column("config_mounts", "mount_path", new_column_name="target_path")
if not _column_exists("config_mounts", "mode"):
op.add_column(
"config_mounts",
sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"),
)
if not _column_exists("config_mounts", "files"):
op.add_column(
"config_mounts",
sa.Column("files", sa.JSON(), nullable=True),
)
for constraint_name in _foreign_key_names_for_column(
"config_mounts", "source_profile_id"
):
op.drop_constraint(constraint_name, "config_mounts", type_="foreignkey")
if _column_exists("config_mounts", "content"):
op.drop_column("config_mounts", "content")
if _column_exists("config_mounts", "source_profile_id"):
op.drop_column("config_mounts", "source_profile_id")
def downgrade() -> None:
# Restore config_mounts
op.add_column(
"config_mounts",
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"config_mounts",
sa.Column("content", sa.Text(), nullable=True),
)
op.drop_column("config_mounts", "files")
op.drop_column("config_mounts", "mode")
op.alter_column("config_mounts", "target_path", new_column_name="mount_path")
# Restore config_profiles
op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles")
op.drop_index("idx_config_profiles_project", table_name="config_profiles")
op.drop_constraint(
"fk_config_profiles_tool_type", "config_profiles", type_="foreignkey"
)
op.drop_constraint("fk_config_profiles_project", "config_profiles", type_="foreignkey")
op.drop_column("config_profiles", "is_default")
op.drop_column("config_profiles", "port")
op.drop_column("config_profiles", "working_directory")
op.drop_column("config_profiles", "start_command")
op.drop_column("config_profiles", "environment_variables")
op.drop_column("config_profiles", "tool_type_id")
op.drop_column("config_profiles", "project_id")
@@ -1,81 +0,0 @@
"""add workspaces table
Revision ID: 2026_06_01_add_workspaces
Revises: 2026_05_29_fix_code_server_bind_addr_port
Create Date: 2026-06-01 10:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_06_01_add_workspaces"
down_revision: str | None = "2026_05_29_fix_code_server_bind_addr_port"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Create workspaces table
op.create_table(
"workspaces",
sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column(
"repo_id",
sa.Uuid(as_uuid=True),
sa.ForeignKey("git_repositories.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Uuid(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("branch", sa.String(255), nullable=False, server_default="main"),
sa.Column("path", sa.String(2048), nullable=False),
sa.Column("status", sa.String(16), nullable=False, server_default="ready"),
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"),
if_not_exists=True,
)
op.create_index("idx_workspaces_repo_id", "workspaces", ["repo_id"])
op.create_index("idx_workspaces_user_id", "workspaces", ["user_id"])
op.create_index("idx_workspaces_status", "workspaces", ["status"])
# Add workspace_id to tool_instances
op.add_column(
"tool_instances",
sa.Column(
"workspace_id",
sa.Uuid(as_uuid=True),
sa.ForeignKey("workspaces.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index(
"idx_tool_instances_workspace_id", "tool_instances", ["workspace_id"]
)
def downgrade() -> None:
op.drop_index("idx_tool_instances_workspace_id", table_name="tool_instances")
op.drop_column("tool_instances", "workspace_id")
op.drop_table("workspaces")
@@ -1,20 +0,0 @@
"""merge profile resolver and workspaces heads
Revision ID: 86cec91fdb00
Revises: 0014_add_profile_resolver_fields, 2026_06_01_add_workspaces
Create Date: 2026-06-03 12:48:36.145702
"""
# revision identifiers, used by Alembic.
revision = "86cec91fdb00"
down_revision = ("0014_add_profile_resolver_fields", "2026_06_01_add_workspaces")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+6 -1
View File
@@ -1 +1,6 @@
"""API routers package.""" 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"]
-6
View File
@@ -1,6 +0,0 @@
"""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"]
@@ -7,25 +7,17 @@ import uuid
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from src.api.shared_validators import validate_env_vars as _validate_env_vars
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import ConfigProfile, ConfigProfileInclude from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.project import Project from src.models.project import Project
from src.models import ToolType from src.models.tool_type import ToolType
from src.models import UserConfig from src.services.config_profile_resolver import (
from src.schemas.config import (
ConfigProfileCreate,
ConfigProfileIncludeUpdate,
ConfigProfileResponse,
ConfigProfileUpdate,
DefaultProfilesUpdate,
ValidateGitUrlRequest,
ValidateGitUrlResponse,
)
from src.services.config.config_profile_resolver import (
ConfigProfileCycleError, ConfigProfileCycleError,
check_include_cycle, check_include_cycle,
resolve_profile, resolve_profile,
@@ -41,6 +33,16 @@ MAX_PROFILE_SIZE_MB = 10
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024 MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
def _validate_uuid(v: str | None) -> str | None:
if v is None:
return v
try:
uuid.UUID(v)
except ValueError:
raise ValueError(f"Invalid UUID: {v}")
return v
def _calculate_profile_size(data: dict) -> int: def _calculate_profile_size(data: dict) -> int:
"""Calculate approximate serialized size of profile data.""" """Calculate approximate serialized size of profile data."""
total = 0 total = 0
@@ -58,6 +60,247 @@ def _calculate_profile_size(data: dict) -> int:
return total return total
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.keys():
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.keys():
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.keys():
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:
raise ValueError(f"Invalid UUID in includes: {item}")
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
async def _get_profile_with_includes( async def _get_profile_with_includes(
session: AsyncSession, profile_id: uuid.UUID session: AsyncSession, profile_id: uuid.UUID
) -> ConfigProfile | None: ) -> ConfigProfile | None:
@@ -597,97 +840,20 @@ async def resolve_default_profile(
return {"profile_id": str(first.id), "profile_name": first.name} return {"profile_id": str(first.id), "profile_name": first.name}
# --------------------------------------------------------------------------- class ValidateGitUrlRequest(BaseModel):
# Default profile management 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"
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") class ValidateGitUrlResponse(BaseModel):
async def get_default_profiles_endpoint( valid: bool
user_id: uuid.UUID = Depends(get_current_user_id), suggested_url: str | None = None
session: AsyncSession = Depends(get_db_session), branches: list[str] | None = None
) -> dict: default_branch: str | None = None
"""Get all default profile mappings for the current user.""" error: str | None = None
result = await session.execute( error_code: str | None = None
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) @router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
@@ -725,8 +891,8 @@ async def validate_git_url(
env = None env = None
key_path = None key_path = None
if data.ssh_key_id: if data.ssh_key_id:
from src.models import SSHKey from src.models.ssh_key import SSHKey
from src.services.shared.ssh_keys import _get_fernet from src.services.ssh_keys import _get_fernet
try: try:
ssh_key_uuid = uuid.UUID(data.ssh_key_id) ssh_key_uuid = uuid.UUID(data.ssh_key_id)
@@ -5,9 +5,9 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models import SSHKey from src.models.ssh_key import SSHKey
router = APIRouter(prefix="/dashboard", tags=["dashboard"]) router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@@ -10,13 +10,13 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from src.auth.dependencies import get_current_user_id from src.auth.dependencies import get_current_user_id
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload from src.services.event_bus import InstanceEventBus, InstanceEventPayload
router = APIRouter(prefix="/events", tags=["events"]) router = APIRouter(prefix="/events", tags=["events"])
# In-memory connection counter per user (single-process assumption) # In-memory connection counter per user (single-process assumption)
_connection_counts: dict[uuid.UUID, int] = {} _connection_counts: dict[uuid.UUID, int] = {}
MAX_CONNECTIONS_PER_USER = 20 MAX_CONNECTIONS_PER_USER = 5
@router.get("/stream") @router.get("/stream")
@@ -3,28 +3,17 @@ import os
import shutil import shutil
import subprocess import subprocess
import uuid import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import ( from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
_get_owned_project,
_get_user,
get_current_user_id,
get_db_session,
)
from src.config import Settings from src.config import Settings
from src.models import GitRepository from src.models.git_repository import GitRepository
from src.models import SSHKey from src.models.ssh_key import SSHKey
from src.schemas.project import (
GitRepositoryCreate,
GitRepositoryResponse,
URLParseRequest,
URLParseResponse,
UpdateSSHKeyRequest,
)
from src.utils.git_files import ( from src.utils.git_files import (
commit_file, commit_file,
get_file_content, get_file_content,
@@ -44,7 +33,7 @@ from src.utils.git_control import (
) )
from src.utils.git_history import get_commit_detail, get_commit_history from src.utils.git_history import get_commit_detail, get_commit_history
from src.utils.git_url_parser import parse_git_url from src.utils.git_url_parser import parse_git_url
from src.services.shared.ssh_keys import _get_fernet from src.services.ssh_keys import _get_fernet
router = APIRouter(prefix="/projects", tags=["git-repositories"]) router = APIRouter(prefix="/projects", tags=["git-repositories"])
@@ -101,9 +90,7 @@ def _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None:
return env, key_path return env, key_path
def _preflight_remote_repository( def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) -> None:
remote_url: str, ssh_key: SSHKey | None = None
) -> None:
"""Verify a remote repository is reachable before cloning.""" """Verify a remote repository is reachable before cloning."""
env = None env = None
key_path = None key_path = None
@@ -122,32 +109,22 @@ def _preflight_remote_repository(
env={**os.environ, **env} if env else None, env={**os.environ, **env} if env else None,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
status_code=status.HTTP_400_BAD_REQUEST,
detail="remote repository check timed out",
)
except FileNotFoundError: except FileNotFoundError:
raise HTTPException( raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="git command not found",
)
finally: finally:
if key_path and os.path.exists(key_path): if key_path and os.path.exists(key_path):
os.unlink(key_path) os.unlink(key_path)
if result.returncode != 0: if result.returncode != 0:
logger.error( logger.error("Preflight check failed for %s: stderr=%s", remote_url, result.stderr)
"Preflight check failed for %s: stderr=%s", remote_url, result.stderr
)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"repository not found or inaccessible: {result.stderr}", detail=f"repository not found or inaccessible: {result.stderr}",
) )
def _clone_working_repository( def _clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | None = None) -> None:
remote_url: str, repo_path: str, ssh_key: SSHKey | None = None
) -> None:
env = None env = None
key_path = None key_path = None
@@ -165,14 +142,9 @@ def _clone_working_repository(
env={**os.environ, **env} if env else None, env={**os.environ, **env} if env else None,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out"
)
except FileNotFoundError: except FileNotFoundError:
raise HTTPException( raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="git command not found",
)
finally: finally:
if key_path and os.path.exists(key_path): if key_path and os.path.exists(key_path):
os.unlink(key_path) os.unlink(key_path)
@@ -193,10 +165,7 @@ def _init_working_repository(repo_path: str) -> None:
text=True, text=True,
) )
except FileNotFoundError: except FileNotFoundError:
raise HTTPException( raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="git command not found",
)
if result.returncode == 0: if result.returncode == 0:
return return
@@ -224,6 +193,43 @@ def _init_working_repository(repo_path: str) -> None:
) )
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
@router.get( @router.get(
"/repositories", "/repositories",
response_model=list[GitRepositoryResponse], response_model=list[GitRepositoryResponse],
@@ -304,10 +310,7 @@ async def create_external_repository(
) )
) )
if existing.scalar_one_or_none(): if existing.scalar_one_or_none():
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
status_code=status.HTTP_400_BAD_REQUEST,
detail="repository name already exists",
)
# Validate and potentially correct the URL # Validate and potentially correct the URL
remote_url = data.remote_url remote_url = data.remote_url
@@ -333,21 +336,13 @@ async def create_external_repository(
try: try:
ssh_key_id = uuid.UUID(data.ssh_key_id) ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError: except ValueError:
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid ssh_key_id format",
)
ssh_key = await session.get(SSHKey, ssh_key_id) ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None: if ssh_key is None:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
if ssh_key.user_id != user_id: if ssh_key.user_id != user_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user")
status_code=status.HTTP_403_FORBIDDEN,
detail="ssh key does not belong to user",
)
if remote_url: if remote_url:
_preflight_remote_repository(remote_url, ssh_key) _preflight_remote_repository(remote_url, ssh_key)
@@ -374,10 +369,7 @@ async def create_external_repository(
repo.is_mirror = False repo.is_mirror = False
except Exception as exc: except Exception as exc:
await session.rollback() await session.rollback()
raise HTTPException( raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}")
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to clone repository: {exc}",
)
else: else:
# Initialize empty repo # Initialize empty repo
os.makedirs(repo_path, exist_ok=True) os.makedirs(repo_path, exist_ok=True)
@@ -446,9 +438,7 @@ async def delete_repository(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
# Remove from disk # Remove from disk
if os.path.exists(repo.path): if os.path.exists(repo.path):
@@ -494,10 +484,7 @@ async def create_repository(
) )
) )
if existing.scalar_one_or_none(): if existing.scalar_one_or_none():
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
status_code=status.HTTP_400_BAD_REQUEST,
detail="repository name already exists",
)
# Validate and potentially correct the URL # Validate and potentially correct the URL
remote_url = data.remote_url remote_url = data.remote_url
@@ -524,21 +511,13 @@ async def create_repository(
try: try:
ssh_key_id = uuid.UUID(data.ssh_key_id) ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError: except ValueError:
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid ssh_key_id format",
)
ssh_key = await session.get(SSHKey, ssh_key_id) ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None: if ssh_key is None:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
if ssh_key.user_id != user_id and ssh_key.project_id != project_id: if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
status_code=status.HTTP_403_FORBIDDEN,
detail="ssh key does not belong to user or project",
)
if remote_url: if remote_url:
_preflight_remote_repository(remote_url, ssh_key) _preflight_remote_repository(remote_url, ssh_key)
@@ -568,6 +547,10 @@ async def create_repository(
return repo return repo
class UpdateSSHKeyRequest(BaseModel):
ssh_key_id: str | None = None
@router.patch( @router.patch(
"/{project_id}/repositories/{repo_id}/ssh-key", "/{project_id}/repositories/{repo_id}/ssh-key",
response_model=GitRepositoryResponse, response_model=GitRepositoryResponse,
@@ -598,30 +581,20 @@ async def update_repository_ssh_key(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
# Validate SSH key if provided # Validate SSH key if provided
if data.ssh_key_id: if data.ssh_key_id:
try: try:
ssh_key_id = uuid.UUID(data.ssh_key_id) ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError: except ValueError:
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid ssh_key_id format",
)
ssh_key = await session.get(SSHKey, ssh_key_id) ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None: if ssh_key is None:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
if ssh_key.user_id != user_id and ssh_key.project_id != project_id: if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
status_code=status.HTTP_403_FORBIDDEN,
detail="ssh key does not belong to user or project",
)
repo.ssh_key_id = ssh_key_id repo.ssh_key_id = ssh_key_id
else: else:
@@ -667,24 +640,16 @@ async def get_repository_history(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
history = get_commit_history( history = get_commit_history(repo.path, branch=branch, limit=limit, offset=offset)
repo.path, branch=branch, limit=limit, offset=offset
)
return history return history
except RuntimeError as e: except RuntimeError as e:
raise HTTPException( raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
)
@router.get( @router.get(
@@ -716,14 +681,10 @@ async def get_repository_commit(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
detail = get_commit_detail(repo.path, commit_hash) detail = get_commit_detail(repo.path, commit_hash)
@@ -802,14 +763,10 @@ async def list_repository_files(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
entries = list_tree(repo.path, branch=branch, path=path) entries = list_tree(repo.path, branch=branch, path=path)
@@ -872,14 +829,10 @@ async def get_repository_file_content(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
file_content = get_file_content(repo.path, branch=branch, path=path) file_content = get_file_content(repo.path, branch=branch, path=path)
@@ -894,9 +847,7 @@ async def get_repository_file_content(
last_commit=file_content.last_commit, last_commit=file_content.last_commit,
) )
except FileNotFoundError: except FileNotFoundError:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
status_code=status.HTTP_404_NOT_FOUND, detail="file not found"
)
except RuntimeError as e: except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@@ -929,16 +880,11 @@ async def get_repository_branches(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
# Try local repo first (.git subdir for normal repos, HEAD for bare) if not os.path.exists(repo.path):
is_valid_git_repo = os.path.isdir( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
os.path.join(repo.path, ".git")
) or os.path.isfile(os.path.join(repo.path, "HEAD"))
if is_valid_git_repo:
try: try:
branches, default_branch = list_branches(repo.path) branches, default_branch = list_branches(repo.path)
return BranchesResponse( return BranchesResponse(
@@ -959,74 +905,7 @@ async def get_repository_branches(
str(e), str(e),
exc_info=True, exc_info=True,
) )
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
) from e
# Local repo missing/corrupt — try remote if available
if repo.remote_url:
ssh_key = None
if repo.ssh_key_id:
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
ssh_result = _prepare_ssh_env(ssh_key)
env = None
key_path = None
if ssh_result:
env, key_path = ssh_result
try:
result = subprocess.run(
["git", "ls-remote", "--heads", repo.remote_url],
capture_output=True,
text=True,
timeout=30,
env={**os.environ, **env} if env else None,
)
if result.returncode == 0:
remote_branches = []
default_branch = "main"
for line in result.stdout.strip().split("\n"):
if line:
parts = line.split("\t")
if len(parts) == 2:
ref = parts[1]
if ref.startswith("refs/heads/"):
branch_name = ref[len("refs/heads/") :]
remote_branches.append(branch_name)
if branch_name in ("main", "master"):
default_branch = branch_name
if remote_branches:
return BranchesResponse(
branches=[
{
"name": b,
"is_default": b == default_branch,
"last_commit": None,
}
for b in remote_branches
],
default_branch=default_branch,
)
else:
logger.warning(
"ls-remote returned %d for repo %s: %s",
result.returncode,
repo_id,
result.stderr,
)
except subprocess.TimeoutExpired:
logger.warning("ls-remote timed out for repo %s", repo_id)
except Exception as e:
logger.warning("ls-remote failed for repo %s: %s", repo_id, str(e))
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="repository not found on disk — re-clone or re-create the repository",
)
@router.post( @router.post(
@@ -1059,14 +938,10 @@ async def update_repository_file(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
# Get user info for commit # Get user info for commit
user = await _get_user(session, user_id) user = await _get_user(session, user_id)
@@ -1134,14 +1009,10 @@ async def get_repository_status(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
status_result = get_status(repo.path) status_result = get_status(repo.path)
@@ -1197,14 +1068,10 @@ async def create_repository_branch(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
create_branch(repo.path, data.name, data.base_branch) create_branch(repo.path, data.name, data.base_branch)
@@ -1244,14 +1111,10 @@ async def delete_repository_branch(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
delete_branch(repo.path, branch_name, force) delete_branch(repo.path, branch_name, force)
@@ -1289,14 +1152,10 @@ async def checkout_repository_branch(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
checkout_branch(repo.path, data.branch) checkout_branch(repo.path, data.branch)
@@ -1345,14 +1204,10 @@ async def commit_repository_changes(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
# Get user info for commit # Get user info for commit
user = await _get_user(session, user_id) user = await _get_user(session, user_id)
@@ -1407,14 +1262,10 @@ async def fetch_repository(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
fetch(repo.path) fetch(repo.path)
@@ -1457,14 +1308,10 @@ async def pull_repository(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
pull(repo.path, branch) pull(repo.path, branch)
@@ -1507,14 +1354,10 @@ async def push_repository(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
push(repo.path, branch) push(repo.path, branch)
@@ -1564,14 +1407,10 @@ async def merge_repository_branches(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
commit_hash = merge( commit_hash = merge(
@@ -5,16 +5,10 @@ from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import text from sqlalchemy import text
from src.database import SessionLocal from src.database import SessionLocal
from src.schemas.system import (
DatabaseHealth,
DatabaseHealthResponse,
DiskHealth,
HealthChecks,
HealthResponse,
)
router = APIRouter() router = APIRouter()
@@ -22,6 +16,45 @@ router = APIRouter()
_start_time = time.time() _start_time = time.time()
class DatabaseHealth(BaseModel):
"""Database health check result."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
class DiskHealth(BaseModel):
"""Disk space health check result."""
status: str = Field(description="Disk health status", examples=["healthy"])
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
class HealthChecks(BaseModel):
"""Individual health checks."""
database: DatabaseHealth | None = None
disk: DiskHealth | None = None
class HealthResponse(BaseModel):
"""Overall health check response."""
status: str = Field(description="Overall health status", examples=["healthy"])
timestamp: str = Field(description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"])
version: str = Field(description="API version", examples=["0.1.0"])
checks: HealthChecks = Field(description="Individual health checks")
uptime_seconds: float = Field(description="Server uptime in seconds", examples=[3600.0])
class DatabaseHealthResponse(BaseModel):
"""Database-specific health check response."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
@router.get( @router.get(
"/health", "/health",
response_model=HealthResponse, response_model=HealthResponse,
@@ -8,8 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import ToolInstance from src.models.tool_instance import ToolInstance
from src.models import ToolType from src.models.tool_type import ToolType
logger = logging.getLogger(__name__) 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.auth.dependencies import get_current_user, get_db_session
from src.models.user import User from src.models.user import User
from src.models import UserConfig from src.models.user_config import UserConfig
from src.services.shared.notification_service import notification_service from src.services.notification_service import notification_service
router = APIRouter(prefix="/notifications", tags=["notifications"]) router = APIRouter(prefix="/notifications", tags=["notifications"])
-6
View File
@@ -1,6 +0,0 @@
"""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"]
@@ -3,29 +3,42 @@ import shutil
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import func, select from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import ( from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
_get_owned_project, from src.models.git_repository import GitRepository
_get_user,
get_current_user_id,
get_db_session,
)
from src.models import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models import SSHKey from src.models.ssh_key import SSHKey
from src.models import ToolInstance
from src.schemas.project import (
ProjectCreate,
ProjectResponse,
ProjectUpdate,
SetDefaultSSHKeyRequest,
)
router = APIRouter(prefix="/projects", tags=["projects"]) router = APIRouter(prefix="/projects", tags=["projects"])
class ProjectCreate(BaseModel):
name: str
description: str | None = None
class ProjectUpdate(BaseModel):
name: str | None = None
description: str | None = None
class ProjectResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
owner_id: uuid.UUID
default_ssh_key_id: uuid.UUID | None
class SetDefaultSSHKeyRequest(BaseModel):
ssh_key_id: uuid.UUID
@router.post( @router.post(
"", "",
response_model=ProjectResponse, response_model=ProjectResponse,
@@ -63,77 +76,26 @@ async def create_project(
@router.get( @router.get(
"", "",
response_model=list[ProjectResponse],
summary="List all projects", summary="List all projects",
description="Retrieve all projects owned by the authenticated user with repositories and workspaces.", description="Retrieve all projects owned by the authenticated user.",
) )
async def list_projects( async def list_projects(
user_id: uuid.UUID = Depends(get_current_user_id), user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session), session: AsyncSession = Depends(get_db_session),
) -> list[dict]: ) -> list[Project]:
"""List all projects for the authenticated user. """List all projects for the authenticated user.
Returns projects with nested repositories and workspaces for inline display. Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of projects owned by the user.
""" """
user = await _get_user(session, user_id) user = await _get_user(session, user_id)
result = await session.execute( result = await session.execute(select(Project).where(Project.owner_id == user.id))
select(Project) return list(result.scalars().all())
.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( @router.get(
@@ -222,9 +184,7 @@ async def delete_project(
project = await _get_owned_project(project_id, user_id, session) project = await _get_owned_project(project_id, user_id, session)
# Delete repositories from disk and database # Delete repositories from disk and database
result = await session.execute( result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
select(GitRepository).where(GitRepository.project_id == project_id)
)
repositories = result.scalars().all() repositories = result.scalars().all()
for repo in repositories: for repo in repositories:
if os.path.exists(repo.path): if os.path.exists(repo.path):
@@ -1,24 +1,18 @@
import base64 import base64
import uuid import uuid
from datetime import datetime
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.config import Settings from src.config import Settings
from src.models import SSHKey from src.models.ssh_key import SSHKey
from src.schemas.project import (
SSHKeyCreate,
SSHKeyResponse,
SignPayloadRequest,
SignatureResponse,
VerifySignatureRequest,
VerifySignatureResponse,
)
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"]) router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
@@ -59,6 +53,36 @@ def generate_ssh_key_pair() -> tuple[str, str]:
return private_bytes.decode("utf-8"), public_bytes.decode("utf-8") return private_bytes.decode("utf-8"), public_bytes.decode("utf-8")
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
@router.post( @router.post(
"", "",
response_model=SSHKeyResponse, response_model=SSHKeyResponse,
@@ -147,9 +171,7 @@ async def delete_ssh_key(
user = await _get_user(session, user_id) user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id) ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id: if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
await session.delete(ssh_key) await session.delete(ssh_key)
await session.commit() await session.commit()
@@ -181,9 +203,7 @@ async def sign_payload(
user = await _get_user(session, user_id) user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id) ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id: if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
fernet = _get_fernet() fernet = _get_fernet()
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode() private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
@@ -222,9 +242,7 @@ async def verify_signature(
user = await _get_user(session, user_id) user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id) ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id: if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode()) public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
-17
View File
@@ -1,17 +0,0 @@
"""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",
]
@@ -12,10 +12,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from starlette.websockets import WebSocketDisconnect from starlette.websockets import WebSocketDisconnect
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import TerminalSessionModel from src.models.terminal_session import TerminalSessionModel
from src.models import ToolInstance from src.models.tool_instance import ToolInstance
from src.models import ToolType from src.models.tool_type import ToolType
from src.services.terminal.terminal_manager import MaxSessionsExceededError, terminal_manager from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -222,7 +222,8 @@ async def _handle_terminal_websocket(
# Use mutable session reference so loops can survive reset # Use mutable session reference so loops can survive reset
session_ref = SessionRef(session, slot_session_id) session_ref = SessionRef(session, slot_session_id)
# Start write loop and heartbeat (read is now event-driven in TerminalSession) # Start I/O loops and heartbeat
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
write_task = asyncio.create_task( write_task = asyncio.create_task(
_write_loop(session_ref, websocket, instance_id) _write_loop(session_ref, websocket, instance_id)
) )
@@ -231,7 +232,7 @@ async def _handle_terminal_websocket(
# Wait for either task to complete (indicating disconnect or error) # Wait for either task to complete (indicating disconnect or error)
done, pending = await asyncio.wait( done, pending = await asyncio.wait(
[write_task, heartbeat_task], [read_task, write_task, heartbeat_task],
return_when=asyncio.FIRST_COMPLETED, return_when=asyncio.FIRST_COMPLETED,
) )
@@ -266,6 +267,28 @@ async def _handle_terminal_websocket(
) )
async def _read_loop(session_ref: SessionRef, websocket) -> None:
"""Read output from the container and send to WebSocket."""
try:
while True:
session = session_ref.session
if not session.is_alive() or session._closed:
await asyncio.sleep(0.1)
continue
data = await session.read_output()
if data:
try:
await websocket.send_bytes(data)
except WebSocketDisconnect:
break
except Exception:
break
else:
await asyncio.sleep(0.01)
except Exception:
pass
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None: async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
"""Read input from WebSocket and send to container.""" """Read input from WebSocket and send to container."""
try: try:
@@ -296,10 +319,6 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
rows, rows,
) )
await session.resize(cols, rows) await session.resize(cols, rows)
elif msg_type == "ack":
char_count = ctrl.get("chars", 0)
if char_count > 0:
session.acknowledge_data(char_count)
elif msg_type == "reset": elif msg_type == "reset":
# Reset terminal session (scoped to current slot) # Reset terminal session (scoped to current slot)
logger.debug( logger.debug(
-13
View File
@@ -1,13 +0,0 @@
"""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
@@ -1,80 +0,0 @@
"""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 sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import ToolDefinitionManifest from src.models.tool_definition_manifest import ToolDefinitionManifest
from src.models import ToolType from src.models.tool_type import ToolType
from src.services.build.manifest_compiler import ( from src.services.manifest_compiler import (
compile_compose, compile_compose,
compile_dockerfile, compile_dockerfile,
compile_entrypoint, compile_entrypoint,
@@ -173,7 +173,7 @@ async def list_tool_definitions(
""" """
query = select(ToolDefinitionManifest) query = select(ToolDefinitionManifest)
if not include_bases: if not include_bases:
query = query.where(ToolDefinitionManifest.is_base.is_(False)) query = query.where(ToolDefinitionManifest.is_base == False)
result = await session.execute( result = await session.execute(
query.order_by(ToolDefinitionManifest.created_at.desc()) query.order_by(ToolDefinitionManifest.created_at.desc())
@@ -11,12 +11,14 @@ from datetime import datetime
import httpx import httpx
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
APIRouter as FastAPIRouter,
Depends, Depends,
HTTPException, HTTPException,
Request, Request,
Response, Response,
status, status,
) )
from pydantic import BaseModel, Field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,15 +28,16 @@ from src.auth.dependencies import (
get_current_user_id, get_current_user_id,
get_db_session, get_db_session,
) )
from src.services.instance.event_bus import InstanceEventBus from src.services.event_bus import InstanceEventBus
from src.services.instance.lifecycle_hooks import publish_lifecycle_event from src.services.lifecycle_hooks import publish_lifecycle_event
from src.models import ConfigProfile from src.models.config_profile import ConfigProfile
from src.models import GitRepository from src.models.git_repository import GitRepository
from src.models import SSHKey from src.models.project import Project
from src.models import ToolInstance from src.models.ssh_key import SSHKey
from src.models import ToolType from src.models.tool_instance import ToolInstance
from src.services.git.clone import check_dirty_state, clone_repository from src.models.tool_type import ToolType
from src.services.config.config_profile_resolver import ( from src.services.clone import check_dirty_state, clone_repository
from src.services.config_profile_resolver import (
ConfigProfileCycleError, ConfigProfileCycleError,
ResolvedProfile, ResolvedProfile,
apply_resolved_profile, apply_resolved_profile,
@@ -42,31 +45,26 @@ from src.services.config.config_profile_resolver import (
resolve_profile, resolve_profile,
) )
from src.services.docker import ( from src.services.docker import (
check_tunnel_health,
connect_container_to_network, connect_container_to_network,
ensure_instance_directory, ensure_instance_directory,
execute_compose_command, execute_compose_command,
find_free_port, find_free_port,
get_backend_network_name,
get_container_id, get_container_id,
get_container_ip_on_network,
get_container_logs, get_container_logs,
get_container_status, get_container_status,
is_container_on_network, recreate_tunnel,
render_compose_template, render_compose_template,
sort_volumes_by_specificity, sort_volumes_by_specificity,
start_cloudflared_tunnel,
stop_cloudflared_tunnel,
wait_for_container_running, wait_for_container_running,
write_compose_file, write_compose_file,
write_config_files, write_config_files,
write_env_file, write_env_file,
) )
from src.services.shared.tunnel import ( from src.services.docker_build import build_image
check_tunnel_health, from src.services.manifest_compiler import (
recreate_tunnel,
start_tunnel,
stop_tunnel,
)
from src.services.build.docker_build import build_image
from src.services.build.manifest_compiler import (
compile_compose, compile_compose,
compile_dockerfile, compile_dockerfile,
compile_entrypoint, compile_entrypoint,
@@ -76,13 +74,9 @@ from src.services.build.manifest_compiler import (
merge_with_config, merge_with_config,
resolve_base, resolve_base,
) )
from src.services.shared.permission_fixer import ( from src.services.permission_fixer import apply_mount_permissions, apply_ssh_permissions
apply_mount_permissions, from src.services.readiness_probe import execute_probe
apply_ssh_permissions, from src.services.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files
)
from src.services.shared.readiness_probe import execute_probe
from src.services.shared.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files
from src.schemas.tool import CreateInstanceRequest, StartInstanceRequest
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_event_bus = InstanceEventBus() _event_bus = InstanceEventBus()
@@ -419,6 +413,45 @@ def _expand_glob_source(source_path: str, repo_path: str) -> list[str]:
router = APIRouter(prefix="/projects", tags=["tool-instances"]) router = APIRouter(prefix="/projects", tags=["tool-instances"])
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"
)
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"
)
async def _validate_config_profile( async def _validate_config_profile(
session: AsyncSession, session: AsyncSession,
profile_id: str | None, profile_id: str | None,
@@ -715,49 +748,6 @@ def _ensure_web_bind_address(
return return
def _ensure_backend_network_in_compose(compose_path: str) -> None:
"""Inject the backend network into the compose file so compose up attaches it.
Instead of running 'docker network connect' after container creation (which
is prone to race conditions and silent failures), we declare the network in
the compose file itself. Docker Compose then connects the container to the
network atomically during 'docker compose up'.
"""
import yaml
from pathlib import Path
compose_file = Path(compose_path)
if not compose_file.exists():
return
content = compose_file.read_text()
compose_data = yaml.safe_load(content)
if not compose_data or "services" not in compose_data:
return
network_name = get_backend_network_name()
modified = False
for svc_config in compose_data["services"].values():
existing = svc_config.get("networks", [])
if network_name not in existing:
svc_config["networks"] = existing + [network_name]
modified = True
break # Only modify first service
# Declare the network as external at the top level
if "networks" not in compose_data:
compose_data["networks"] = {}
if network_name not in compose_data["networks"]:
compose_data["networks"][network_name] = {"external": True}
modified = True
if modified:
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
logger.info("Injected backend network '%s' into compose file", network_name)
@router.post( @router.post(
"/{project_id}/repositories/{repo_id}/instances", "/{project_id}/repositories/{repo_id}/instances",
summary="Create tool instance", summary="Create tool instance",
@@ -811,34 +801,9 @@ async def create_instance(
session, data.config_profile_id, user_id, project_id, tool_type_id session, data.config_profile_id, user_id, project_id, tool_type_id
) )
# Resolve workspace if provided
workspace = None
workspace_id = None
if data.workspace_id:
from src.models import Workspace as WorkspaceModel
try: try:
workspace_id = uuid.UUID(data.workspace_id) # Validate clone mode requirements
except ValueError: if data.clone_mode == "clone":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid workspace_id format",
)
workspace = await session.get(WorkspaceModel, workspace_id)
if workspace is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="workspace not found",
)
if workspace.repo_id != repo_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="workspace does not belong to this repository",
)
try:
# Validate clone mode requirements (legacy path)
if data.clone_mode == "clone" and not workspace:
if not repo.remote_url: if not repo.remote_url:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -852,37 +817,9 @@ async def create_instance(
# Generate unique name # Generate unique name
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}" instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
instance_display = (
# Auto-generate display name with scoped numbering. data.display_name or f"{tool_type.display_name} - {repo.name}"
# When a workspace is provided, use workspace name + tool type.
# Otherwise fall back to repo name + tool type.
if data.display_name:
instance_display = data.display_name
else:
scope_name = workspace.name if workspace else repo.name
auto_name = f"{scope_name} / {tool_type.display_name}"
if workspace:
count_query = (
select(ToolInstance)
.where(ToolInstance.workspace_id == workspace_id)
.where(ToolInstance.tool_type_id == tool_type_id)
.where(ToolInstance.owner_id == user_id)
) )
else:
count_query = (
select(ToolInstance)
.where(ToolInstance.repository_id == repo_id)
.where(ToolInstance.tool_type_id == tool_type_id)
.where(ToolInstance.owner_id == user_id)
)
result = await session.execute(count_query)
existing_count = len(result.scalars().all())
if existing_count > 0:
instance_display = f"{auto_name} #{existing_count + 1}"
else:
instance_display = auto_name
# Create instance directory # Create instance directory
instance_dir = ensure_instance_directory(instance_name) instance_dir = ensure_instance_directory(instance_name)
@@ -891,10 +828,8 @@ async def create_instance(
# Find free port # Find free port
tool_port = find_free_port() tool_port = find_free_port()
# Determine repo path based on workspace or clone mode # Determine repo path based on clone mode
if workspace: if data.clone_mode == "clone":
repo_path = workspace.path
elif data.clone_mode == "clone":
# Get SSH key for cloning # Get SSH key for cloning
ssh_key = await session.get(SSHKey, repo.ssh_key_id) ssh_key = await session.get(SSHKey, repo.ssh_key_id)
if ssh_key is None: if ssh_key is None:
@@ -1026,8 +961,8 @@ services:
write_compose_file(instance_dir, compose_content) write_compose_file(instance_dir, compose_content)
elif tool_type.definition_type == "manifest": elif tool_type.definition_type == "manifest":
# Manifest-based: generate compose only; image built lazily on start # Manifest-based: build image and generate compose
from src.models import ToolDefinitionManifest from src.models.tool_definition_manifest import ToolDefinitionManifest
manifest_def = await session.get( manifest_def = await session.get(
ToolDefinitionManifest, tool_type.manifest_id ToolDefinitionManifest, tool_type.manifest_id
@@ -1048,8 +983,44 @@ services:
deep_merge(dict(base_def.manifest), manifest) deep_merge(dict(base_def.manifest), manifest)
) )
# Determine home directory for path expansion
_home_dir = get_manifest_home_dir(manifest)
image_tag = compute_image_tag(tool_type.name, manifest) image_tag = compute_image_tag(tool_type.name, manifest)
# Build image during creation so start is fast
dockerfile = compile_dockerfile(manifest)
entrypoint = compile_entrypoint(manifest)
build_ctx = {
"Dockerfile": dockerfile,
".headquarter/entrypoint.sh": entrypoint,
}
returncode, stdout, stderr = await asyncio.to_thread(
build_image,
instance_dir=instance_dir,
dockerfile=dockerfile,
tag=image_tag,
build_context=build_ctx,
)
if returncode != 0:
logger.error(
"Failed to build image for manifest instance %s: %s",
instance_name,
stderr,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to build Docker image: {stderr[:500]}",
)
logger.info(
"Built manifest image %s for instance %s",
image_tag,
instance_name,
)
variables = { variables = {
"IMAGE_TAG": image_tag, "IMAGE_TAG": image_tag,
"INSTANCE_NAME": instance_name.lower(), "INSTANCE_NAME": instance_name.lower(),
@@ -1124,7 +1095,6 @@ services:
status="pending", status="pending",
compose_path=compose_path, compose_path=compose_path,
port=tool_port, port=tool_port,
workspace_id=workspace_id,
clone_mode=data.clone_mode, clone_mode=data.clone_mode,
branch=data.new_branch branch=data.new_branch
if data.new_branch if data.new_branch
@@ -1312,7 +1282,7 @@ async def _prepare_manifest_instance(
Returns: Returns:
Tuple of (image_tag, compose_content, resolved_manifest, home_dir) Tuple of (image_tag, compose_content, resolved_manifest, home_dir)
""" """
from src.models import ToolDefinitionManifest from src.models.tool_definition_manifest import ToolDefinitionManifest
tool_type = await session.get(ToolType, instance.tool_type_id) tool_type = await session.get(ToolType, instance.tool_type_id)
manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id) manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id)
@@ -1417,18 +1387,6 @@ async def _prepare_manifest_instance(
compose_content = compile_compose(manifest, variables) compose_content = compile_compose(manifest, variables)
logger.debug(
"_prepare_manifest_instance for %s: repo_path=%s compose_volumes=%s",
instance.id,
repo_path or "<empty>",
manifest.get("mounts", []),
)
logger.debug(
"Generated compose for %s:\n%s",
instance.id,
compose_content,
)
# Cache # Cache
instance.image_tag = image_tag instance.image_tag = image_tag
instance.manifest_compiled_at = datetime.now() instance.manifest_compiled_at = datetime.now()
@@ -1508,7 +1466,7 @@ async def start_instance(
container_uid = 0 container_uid = 0
container_gid = 0 container_gid = 0
if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id: if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
from src.models import ToolDefinitionManifest from src.models.tool_definition_manifest import ToolDefinitionManifest
manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id) manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id)
if manifest_def: if manifest_def:
@@ -1598,79 +1556,17 @@ async def start_instance(
# Mount selected SSH keys into container home dir # Mount selected SSH keys into container home dir
if instance.ssh_key_ids: if instance.ssh_key_ids:
from src.services.shared.ssh_keys import write_ssh_config, _sanitize_filename
# Collect all valid keys first
ssh_keys_to_mount = []
for key_id in instance.ssh_key_ids: for key_id in instance.ssh_key_ids:
ssh_key = await session.get(SSHKey, uuid.UUID(key_id)) ssh_key = await session.get(SSHKey, uuid.UUID(key_id))
if ssh_key and ssh_key.user_id == user_id: if ssh_key and ssh_key.user_id == user_id:
ssh_keys_to_mount.append(ssh_key)
else:
logger.warning(
"SSH key %s not found or not authorized for user %s",
key_id,
user_id,
)
if ssh_keys_to_mount:
# Use a single shared .ssh directory so all keys are visible
ssh_dir = os.path.join(instance_dir, "mounts", "ssh", ".ssh")
os.makedirs(ssh_dir, exist_ok=True)
key_filenames = []
for ssh_key in ssh_keys_to_mount:
# Use sanitized key name as filename prefix to avoid collisions
key_name = _sanitize_filename(ssh_key.name)
# If multiple keys have the same name, append a short hash
base_filename = f"id_ed25519_{key_name}"
filename = base_filename
counter = 1
while filename in key_filenames:
filename = f"{base_filename}_{counter}"
counter += 1
key_filenames.append(filename)
try: try:
prepare_ssh_key_files( ssh_dir = prepare_ssh_key_files(
instance_dir, instance_dir,
ssh_key, ssh_key,
subdir="mounts/ssh/.ssh", subdir=f"mounts/ssh/{key_id}/.ssh",
uid=container_uid,
gid=container_gid,
key_filename=filename,
write_config=False,
)
logger.debug(
"Prepared SSH key %s as %s for instance %s",
ssh_key.name,
filename,
instance.id,
)
except Exception as exc:
logger.error(
"Failed to prepare SSH key %s for instance %s: %s",
ssh_key.id,
instance.id,
exc,
)
# Write combined SSH config with all keys
try:
write_ssh_config(
ssh_dir,
key_filenames,
uid=container_uid, uid=container_uid,
gid=container_gid, gid=container_gid,
) )
except Exception as exc:
logger.error(
"Failed to write SSH config for instance %s: %s",
instance.id,
exc,
)
# Mount the single .ssh directory into container home
ssh_target = os.path.join(home_dir, ".ssh") ssh_target = os.path.join(home_dir, ".ssh")
extra_volumes.append( extra_volumes.append(
{ {
@@ -1680,11 +1576,24 @@ async def start_instance(
} }
) )
logger.debug( logger.debug(
"Mounted %d SSH key(s) for instance %s to %s", "Mounted SSH key %s for instance %s to %s",
len(ssh_keys_to_mount), ssh_key.name,
instance.id, instance.id,
ssh_target, ssh_target,
) )
except Exception as exc:
logger.error(
"Failed to prepare SSH key %s for instance %s: %s",
key_id,
instance.id,
exc,
)
else:
logger.warning(
"SSH key %s not found or not authorized for user %s",
key_id,
user_id,
)
# ── MANIFEST-BASED FLOW ────────────────────────────────────── # ── MANIFEST-BASED FLOW ──────────────────────────────────────
resolved_manifest = None resolved_manifest = None
@@ -1692,15 +1601,7 @@ async def start_instance(
if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id: if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
logger.info("Using manifest-based startup for instance %s", instance.id) logger.info("Using manifest-based startup for instance %s", instance.id)
# Determine repo path (workspace takes precedence) # Determine repo path
repo_path = ""
if instance.workspace_id:
from src.models import Workspace as WorkspaceModel
workspace = await session.get(WorkspaceModel, instance.workspace_id)
if workspace:
repo_path = workspace.path
else:
repo = await session.get(GitRepository, instance.repository_id) repo = await session.get(GitRepository, instance.repository_id)
repo_path = repo.path if repo else "" repo_path = repo.path if repo else ""
if instance.clone_mode == "clone": if instance.clone_mode == "clone":
@@ -1737,8 +1638,8 @@ async def start_instance(
) )
else: else:
# ── LEGACY FLOW ────────────────────────────────────────── # ── LEGACY FLOW ──────────────────────────────────────────
# Mount SSH key for clone-mode instances (skip for workspace-based) # Mount SSH key for clone-mode instances
if instance.clone_mode == "clone" and not instance.workspace_id: if instance.clone_mode == "clone":
repo = await session.get(GitRepository, instance.repository_id) repo = await session.get(GitRepository, instance.repository_id)
if repo and repo.ssh_key_id: if repo and repo.ssh_key_id:
ssh_key = await session.get(SSHKey, repo.ssh_key_id) ssh_key = await session.get(SSHKey, repo.ssh_key_id)
@@ -1787,7 +1688,6 @@ async def start_instance(
# Ensure predictable container name for tunnel connectivity # Ensure predictable container name for tunnel connectivity
_ensure_container_name_in_compose(instance.compose_path, instance.name) _ensure_container_name_in_compose(instance.compose_path, instance.name)
_ensure_backend_network_in_compose(instance.compose_path)
# Execute docker compose up with env file # Execute docker compose up with env file
logger.debug( logger.debug(
@@ -1823,9 +1723,15 @@ async def start_instance(
logger.debug("Container ID for instance %s: %s", instance.id, container_id) logger.debug("Container ID for instance %s: %s", instance.id, container_id)
instance.container_name = expected_container_name instance.container_name = expected_container_name
logger.debug( logger.debug("Container name for instance %s: %s", instance.id, expected_container_name)
"Container name for instance %s: %s", instance.id, expected_container_name
) # Connect container to backend network so API can reach it
logger.debug("Connecting container %s to backend network...", expected_container_name)
connected = connect_container_to_network(expected_container_name, "backend")
if connected:
logger.debug("Successfully connected %s to backend network", expected_container_name)
else:
logger.warning("Failed to connect %s to backend network", expected_container_name)
# Verify container reached running state # Verify container reached running state
if instance.container_id: if instance.container_id:
@@ -2046,11 +1952,12 @@ async def start_instance(
"error": f"Tool type '{instance.tool_type_id}' not found", "error": f"Tool type '{instance.tool_type_id}' not found",
} }
instance_port = tool_type.default_port or 0
logger.debug( logger.debug(
"Tool type for instance %s: name=%s, container_port=%s, interface_type=%s", "Tool type for instance %s: name=%s, default_port=%s, interface_type=%s",
instance.id, instance.id,
tool_type.name, tool_type.name,
tool_type.default_port or 0, instance_port,
tool_type.interface_type, tool_type.interface_type,
) )
@@ -2059,22 +1966,23 @@ async def start_instance(
# Create temporary Cloudflare tunnel for public access # Create temporary Cloudflare tunnel for public access
try: try:
logger.debug( logger.debug(
"Creating tunnel for instance %s (container_port=%d)", "Creating temporary tunnel for instance %s (container=%s, port=%d)",
instance.id, instance.id,
tool_type.default_port or 0, instance.container_name,
instance_port,
) )
tunnel_info = start_tunnel( tunnel_info = start_cloudflared_tunnel(
instance_name=instance.name, container_name=instance.container_name or instance.name,
container_port=tool_type.default_port or 0, port=instance_port,
) )
instance.tunnel_id = tunnel_info["container_name"] instance.tunnel_id = tunnel_info["pid"]
instance.public_url = tunnel_info["url"] instance.public_url = tunnel_info["url"]
instance.url = tunnel_info["url"] instance.url = tunnel_info["url"]
await session.commit() await session.commit()
logger.debug( logger.debug(
"Created tunnel for instance %s: container=%s, url=%s", "Created temporary tunnel for instance %s: pid=%s, url=%s",
instance.id, instance.id,
tunnel_info["container_name"], tunnel_info["pid"],
tunnel_info["url"], tunnel_info["url"],
) )
except Exception as exc: except Exception as exc:
@@ -2144,9 +2052,9 @@ async def stop_instance(
# Stop Cloudflare tunnel if exists # Stop Cloudflare tunnel if exists
if instance.tunnel_id: if instance.tunnel_id:
try: try:
stop_tunnel(instance.name) stop_cloudflared_tunnel(instance.tunnel_id)
logger.debug( logger.debug(
"Stopped tunnel for instance %s (container=%s)", "Stopped tunnel for instance %s (pid=%s)",
instance.id, instance.id,
instance.tunnel_id, instance.tunnel_id,
) )
@@ -2213,9 +2121,9 @@ async def restart_instance(
# Stop old tunnel if exists # Stop old tunnel if exists
if instance.tunnel_id: if instance.tunnel_id:
try: try:
stop_tunnel(instance.name) stop_cloudflared_tunnel(instance.tunnel_id)
logger.debug( logger.debug(
"Stopped old tunnel for instance %s (container=%s)", "Stopped old tunnel for instance %s (pid=%s)",
instance.id, instance.id,
instance.tunnel_id, instance.tunnel_id,
) )
@@ -2258,7 +2166,6 @@ async def restart_instance(
instance.compose_path, tool_type.name, tool_type.default_port instance.compose_path, tool_type.name, tool_type.default_port
) )
_ensure_container_name_in_compose(instance.compose_path, instance.name) _ensure_container_name_in_compose(instance.compose_path, instance.name)
_ensure_backend_network_in_compose(instance.compose_path)
returncode, stdout, stderr = execute_compose_command( returncode, stdout, stderr = execute_compose_command(
instance.compose_path, "restart" instance.compose_path, "restart"
@@ -2282,15 +2189,17 @@ async def restart_instance(
"error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured", "error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured",
} }
instance_port = tool_type.default_port
# Only create tunnel for web-enabled tools # Only create tunnel for web-enabled tools
if tool_type.interface_type == "web": if tool_type.interface_type == "web":
# Create new tunnel # Create new temporary tunnel
try: try:
tunnel_info = start_tunnel( tunnel_info = start_cloudflared_tunnel(
instance_name=instance.name, container_name=instance.name.lower(),
container_port=tool_type.default_port or 0, port=instance_port,
) )
instance.tunnel_id = tunnel_info["container_name"] instance.tunnel_id = tunnel_info["pid"]
instance.public_url = tunnel_info["url"] instance.public_url = tunnel_info["url"]
instance.url = tunnel_info["url"] instance.url = tunnel_info["url"]
logger.debug( logger.debug(
@@ -2389,9 +2298,9 @@ async def delete_instance(
# Stop Cloudflare tunnel if exists # Stop Cloudflare tunnel if exists
if instance.tunnel_id: if instance.tunnel_id:
try: try:
stop_tunnel(instance.name) stop_cloudflared_tunnel(instance.tunnel_id)
logger.debug( logger.debug(
"Stopped tunnel for instance %s (container=%s)", "Stopped tunnel for instance %s (pid=%s)",
instance.id, instance.id,
instance.tunnel_id, instance.tunnel_id,
) )
@@ -2506,117 +2415,43 @@ async def recreate_tunnel_endpoint(
detail="instance must be running to recreate tunnel", detail="instance must be running to recreate tunnel",
) )
# Validate tunnel is actually broken before recreating
if instance.url:
tunnel_health = check_tunnel_health(instance.url)
if tunnel_health["tunnel_status"] == "error_response":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Tunnel is working but application returned HTTP {tunnel_health.get('status_code')}. Recreating the tunnel will not fix this issue.",
)
elif tunnel_health["tunnel_status"] == "healthy":
return {
"status": "healthy",
"url": instance.url,
"message": "Tunnel is already healthy",
}
# Get tool type for default port
tool_type = await session.get(ToolType, instance.tool_type_id) tool_type = await session.get(ToolType, instance.tool_type_id)
if not tool_type: instance_port = (
raise HTTPException( tool_type.default_port if tool_type and tool_type.default_port else 8080
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tool type not found for this instance",
)
expected_name = instance.name.lower()
logger.info(
"Recreate tunnel for instance %s (expected container name: %s, default_port: %s)",
instance.id,
expected_name,
tool_type.default_port,
)
# Find the tool container — try stored ID first, then fall back to name lookup
tool_container_id = instance.container_id
if tool_container_id:
logger.info("Using stored container_id: %s", tool_container_id)
else:
tool_container_id = get_container_id(expected_name)
if tool_container_id:
logger.info("Found container by name: %s", tool_container_id)
else:
logger.error("Container %s not found", expected_name)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Could not find running container for this instance",
)
# Ensure the tool container is on the backend network so the tunnel can reach it
network_name = get_backend_network_name()
on_network = is_container_on_network(tool_container_id, network_name)
logger.info(
"Container %s on network %s: %s",
tool_container_id,
network_name,
on_network,
)
if not on_network:
logger.info(
"Connecting container %s to network %s",
tool_container_id,
network_name,
)
connected = connect_container_to_network(tool_container_id, network_name)
logger.info("Network connect result: %s", connected)
# Get the container's IP on the backend network
target_ip = get_container_ip_on_network(tool_container_id, network_name)
if target_ip:
target_url = f"http://{target_ip}:{tool_type.default_port or 0}"
logger.info(
"Tunnel target for instance %s: %s (IP %s on %s)",
instance.id,
target_url,
target_ip,
network_name,
)
else:
target_url = f"http://{expected_name}:{tool_type.default_port or 0}"
logger.warning(
"Could not get container IP, falling back to name-based target: %s",
target_url,
) )
try: try:
tunnel_info = recreate_tunnel( tunnel_info = recreate_tunnel(
instance_name=instance.name, container_name=instance.container_name or instance.name,
container_port=tool_type.default_port or 0, port=instance_port,
target_url=target_url, old_pid=instance.tunnel_id,
) )
logger.info( instance.tunnel_id = tunnel_info["pid"]
"Tunnel recreated: container=%s, url=%s",
tunnel_info["container_name"],
tunnel_info["url"],
)
# Verify the tunnel can actually reach the origin
health = check_tunnel_health(tunnel_info["url"], timeout=10)
logger.info(
"Tunnel health check: status=%s, code=%s, error=%s",
health.get("tunnel_status"),
health.get("status_code"),
health.get("error"),
)
# Also probe from inside the API container directly to the target
probe = subprocess.run(
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--max-time",
"5",
target_url,
],
capture_output=True,
text=True,
)
logger.info(
"Direct probe from API to %s: HTTP %s", target_url, probe.stdout.strip()
)
instance.tunnel_id = tunnel_info["container_name"]
instance.public_url = tunnel_info["url"] instance.public_url = tunnel_info["url"]
instance.url = tunnel_info["url"] instance.url = tunnel_info["url"]
await session.commit() await session.commit()
logger.debug(
"Recreated tunnel for instance %s: pid=%s, url=%s",
instance.id,
tunnel_info["pid"],
tunnel_info["url"],
)
return {"status": "healthy", "url": instance.url} return {"status": "healthy", "url": instance.url}
except Exception as exc: except Exception as exc:
logger.exception("Failed to recreate tunnel for instance %s", instance.id) logger.exception("Failed to recreate tunnel for instance %s", instance.id)
@@ -2738,7 +2573,7 @@ async def get_instance_events(
List of event dictionaries. List of event dictionaries.
""" """
from sqlalchemy import select from sqlalchemy import select
from src.models import InstanceEvent from src.models.instance_event import InstanceEvent
_user = await _get_user(session, user_id) _user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session) _project = await _get_owned_project(project_id, user_id, session)
@@ -2898,3 +2733,71 @@ async def proxy_to_instance(
status_code=response.status_code, status_code=response.status_code,
headers=response_headers, headers=response_headers,
) )
sessions_router = FastAPIRouter(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}
@@ -1,23 +1,19 @@
import uuid import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.api.tool.tool_types_validation import ( from src.api.tool_types_validation import (
check_port_exposed, check_port_exposed,
validate_compose_yaml, validate_compose_yaml,
validate_required_variables, validate_required_variables,
) )
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models import ToolType from src.models.tool_type import ToolType
from src.models.user import User from src.models.user import User
from src.schemas.tool import (
ToolTypeCreate,
ToolTypeResponse,
ToolTypeUpdate,
ToolTypeValidateRequest,
)
router = APIRouter(prefix="/tool-types", tags=["tool-types"]) router = APIRouter(prefix="/tool-types", tags=["tool-types"])
@@ -33,6 +29,237 @@ async def _require_admin(user: User) -> None:
pass pass
class ToolTypeCreate(BaseModel):
name: str
display_name: str
description: str | None = None
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"
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", "manifest"):
raise ValueError(
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
)
return v
@field_validator("compose_template")
@classmethod
def validate_compose_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "compose":
return v
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")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "dockerfile":
return v
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")
return v
@field_validator("required_variables")
@classmethod
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"
)
return v
@model_validator(mode="after")
def validate_templates(self) -> "ToolTypeCreate":
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'"
)
# Validate that default_port is exposed in compose template (only if requires_port)
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
class ToolTypeUpdate(BaseModel):
display_name: str | None = None
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
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", "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")
@classmethod
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
validate_compose_yaml(v)
return v
@field_validator("dockerfile_template")
@classmethod
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
class ToolTypeResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
display_name: str
description: str | None
category: 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]
created_by_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
@router.post( @router.post(
"", "",
response_model=ToolTypeResponse, response_model=ToolTypeResponse,
@@ -234,6 +461,12 @@ async def update_tool_type(
return tool_type return tool_type
class ToolTypeValidateRequest(BaseModel):
definition_type: str
compose_template: str | None = None
dockerfile_template: str | None = None
@router.post( @router.post(
"/validate", "/validate",
summary="Validate tool type template", summary="Validate tool type template",
-7
View File
@@ -1,7 +0,0 @@
"""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"]
@@ -2,12 +2,12 @@ import logging
import uuid import uuid
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models import UserConfig from src.models.user_config import UserConfig
from src.schemas.user import UserConfigResponse, UserConfigUpdate
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,6 +38,28 @@ async def _get_or_create_config(
return config return config
class UserConfigResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
default_editor: str | None = None
theme: str = "system"
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):
default_editor: str | None = None
theme: str | None = None
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
@router.get( @router.get(
"/config", "/config",
response_model=UserConfigResponse, response_model=UserConfigResponse,
@@ -2,11 +2,11 @@ import uuid
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models.user import User from src.models.user import User
from src.schemas.user import UserProfileResponse, UserProfileUpdate
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@@ -16,6 +16,20 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
class UserProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name: str
avatar_url: str | None
class UserProfileUpdate(BaseModel):
name: str | None = None
email: str | None = None
@router.get( @router.get(
"/me", "/me",
response_model=UserProfileResponse, response_model=UserProfileResponse,
@@ -63,16 +77,12 @@ async def update_profile(
if data.name is not None: if data.name is not None:
if len(data.name.strip()) == 0: if len(data.name.strip()) == 0:
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty")
status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty"
)
user.name = data.name.strip() user.name = data.name.strip()
if data.email is not None: if data.email is not None:
if "@" not in data.email: if "@" not in data.email:
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email")
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email"
)
user.email = data.email.strip() user.email = data.email.strip()
await session.commit() await session.commit()
-17
View File
@@ -1,17 +0,0 @@
"""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",
]
@@ -1,114 +0,0 @@
"""Workspace file API endpoints."""
import uuid
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 import Workspace
from src.services.shared.file_service import FileService
router = APIRouter(prefix="/workspaces/{workspace_id}/files")
async def _get_workspace(
session: AsyncSession,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
) -> Workspace:
from sqlalchemy import select
result = await session.execute(
select(Workspace).where(
Workspace.id == workspace_id,
Workspace.user_id == user_id,
)
)
workspace = result.scalar_one_or_none()
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
return workspace
@router.get("/")
async def list_files(
workspace_id: uuid.UUID,
path: str = "",
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List files in a workspace directory."""
workspace = await _get_workspace(session, workspace_id, user_id)
service = FileService()
try:
entries = service.list_directory(workspace, path)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {
"entries": [
{
"name": e.name,
"path": e.path,
"type": e.type,
"size": e.size,
}
for e in entries
],
}
@router.get("/content")
async def get_file_content(
workspace_id: uuid.UUID,
path: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get the content of a text file."""
workspace = await _get_workspace(session, workspace_id, user_id)
service = FileService()
try:
content = service.read_file(workspace, path)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"content": content, "path": path}
@router.post("/content")
async def write_file(
workspace_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Write a file and optionally commit."""
workspace = await _get_workspace(session, workspace_id, user_id)
service = FileService()
file_path = data.get("path", "").strip()
content = data.get("content", "")
commit_message = data.get("message", "").strip()
if not file_path:
raise HTTPException(status_code=400, detail="File path is required")
try:
service.write_file(workspace, file_path, content)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if commit_message:
from src.services.git.git_operations import GitOperations
git = GitOperations(workspace)
try:
await git.commit(commit_message)
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "saved", "path": file_path}
-203
View File
@@ -1,203 +0,0 @@
"""Workspace git API endpoints."""
import uuid
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 import Workspace
from src.services.git.git_operations import GitOperations
router = APIRouter(prefix="/workspaces/{workspace_id}/git")
async def _get_workspace(
session: AsyncSession,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
) -> Workspace:
from sqlalchemy import select
result = await session.execute(
select(Workspace).where(
Workspace.id == workspace_id,
Workspace.user_id == user_id,
)
)
workspace = result.scalar_one_or_none()
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
return workspace
@router.get("/status")
async def git_status(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get git status for the workspace."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
status = await git.status()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {
"branch": status.branch,
"modified": status.modified,
"added": status.added,
"deleted": status.deleted,
"untracked": status.untracked,
"ahead": status.ahead,
"behind": status.behind,
}
@router.get("/branches")
async def git_branches(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List branches for the workspace."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
branches, current = await git.branches()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {
"branches": branches,
"current_branch": current,
}
@router.post("/commit")
async def git_commit(
workspace_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Stage all changes and commit."""
workspace = await _get_workspace(session, workspace_id, user_id)
message = data.get("message", "").strip()
if not message:
raise HTTPException(status_code=400, detail="Commit message is required")
git = GitOperations(workspace)
try:
await git.commit(message)
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "committed"}
@router.post("/push")
async def git_push(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Push current branch."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
await git.push()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "pushed"}
@router.post("/pull")
async def git_pull(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Pull current branch."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
await git.pull()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "pulled"}
@router.post("/fetch")
async def git_fetch(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Fetch from origin."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
await git.fetch()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "fetched"}
@router.post("/checkout")
async def git_checkout(
workspace_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Checkout a branch."""
workspace = await _get_workspace(session, workspace_id, user_id)
branch = data.get("branch", "").strip()
if not branch:
raise HTTPException(status_code=400, detail="Branch name is required")
git = GitOperations(workspace)
try:
await git.checkout(branch)
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
workspace.branch = branch
await session.commit()
return {"status": "checked_out", "branch": branch}
@router.get("/history")
async def git_history(
workspace_id: uuid.UUID,
path: str | None = None,
limit: int = 50,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get commit history."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
commits = await git.history(path, limit)
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {
"commits": [
{
"hash": c.hash,
"message": c.message,
"author": c.author,
"date": c.date,
}
for c in commits
],
}
@@ -1,60 +0,0 @@
"""Workspace instance API endpoints."""
import uuid
from fastapi import APIRouter, Depends, HTTPException
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 import ToolInstance
from src.models import Workspace
router = APIRouter(prefix="/workspaces/{workspace_id}/instances")
async def _get_workspace(
session: AsyncSession,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
) -> Workspace:
result = await session.execute(
select(Workspace).where(
Workspace.id == workspace_id,
Workspace.user_id == user_id,
)
)
workspace = result.scalar_one_or_none()
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
return workspace
@router.get("/")
async def list_workspace_instances(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[dict]:
"""List tool instances using this workspace."""
await _get_workspace(session, workspace_id, user_id)
result = await session.execute(
select(ToolInstance)
.where(ToolInstance.workspace_id == workspace_id)
.order_by(ToolInstance.created_at.desc())
)
instances = result.scalars().all()
return [
{
"id": str(i.id),
"name": i.name,
"display_name": i.display_name,
"status": i.status,
"tool_type_id": str(i.tool_type_id),
"url": i.url,
"port": i.port,
"created_at": i.created_at.isoformat() if i.created_at else None,
}
for i in instances
]
-450
View File
@@ -1,450 +0,0 @@
"""Workspace CRUD API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, 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 GitRepository
from src.models import ToolInstance
from src.models import Workspace
from src.services.shared.workspace_manager import WorkspaceHasInstancesError, WorkspaceManager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces")
all_workspaces_router = APIRouter(prefix="/workspaces")
@all_workspaces_router.get("/")
async def list_all_workspaces(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[dict]:
"""List all workspaces for the current user across all repos."""
instance_count = (
select(func.count(ToolInstance.id))
.where(ToolInstance.workspace_id == Workspace.id)
.correlate(Workspace)
.scalar_subquery()
)
result = await session.execute(
select(
Workspace,
GitRepository.name.label("repo_name"),
GitRepository.project_id,
GitRepository.ssh_key_id.label("repo_ssh_key_id"),
instance_count.label("instance_count"),
)
.join(GitRepository, Workspace.repo_id == GitRepository.id)
.where(Workspace.user_id == user_id)
.order_by(Workspace.created_at.desc())
)
rows = result.all()
return [
{
"id": str(ws.id),
"name": ws.name,
"repo_id": str(ws.repo_id),
"repo_name": repo_name or "",
"repo_ssh_key_id": str(ssh_key_id) if ssh_key_id else None,
"project_id": str(project_id) if project_id else "",
"project_name": "",
"user_id": str(ws.user_id),
"branch": ws.branch,
"path": ws.path,
"status": ws.status,
"last_sync_at": ws.last_sync_at.isoformat() if ws.last_sync_at else None,
"created_at": ws.created_at.isoformat() if ws.created_at else None,
"updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
"instance_count": count or 0,
}
for ws, repo_name, project_id, ssh_key_id, count in rows
]
@all_workspaces_router.delete("/{workspace_id}")
async def delete_workspace_top_level(
workspace_id: uuid.UUID,
force: bool = Query(False),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Delete a workspace via top-level path."""
workspace = await session.get(Workspace, workspace_id)
if not workspace or workspace.user_id != user_id:
raise HTTPException(status_code=404, detail="Workspace not found")
manager = WorkspaceManager()
try:
await manager.delete(workspace, force=force, session=session)
await session.commit()
except WorkspaceHasInstancesError as exc:
await session.rollback()
raise HTTPException(
status_code=409,
detail={
"message": "Workspace has running tool instances",
"instances": exc.instances,
},
) from exc
except Exception as exc:
await session.rollback()
logger.error("Failed to delete workspace: %s", exc)
raise HTTPException(
status_code=500, detail="Failed to delete workspace"
) from exc
return {"status": "deleted"}
@all_workspaces_router.post("/")
async def create_workspace_top_level(
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a workspace directly (no nested project/repo path)."""
repo_id_str = data.get("repo_id", "").strip()
if not repo_id_str:
raise HTTPException(status_code=400, detail="repo_id is required")
try:
repo_id = uuid.UUID(repo_id_str)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid repo_id format") from exc
repo = await session.get(GitRepository, repo_id)
if not repo or repo.owner_id != user_id:
raise HTTPException(status_code=404, detail="Repository not found")
name = data.get("name", "").strip()
branch = data.get("branch", "main").strip()
if not name:
raise HTTPException(status_code=400, detail="Workspace name is required")
manager = WorkspaceManager()
try:
workspace = await manager.create(repo, user_id, name, branch, session=session)
session.add(workspace)
await session.commit()
except Exception as exc:
await session.rollback()
logger.error("Failed to create workspace: %s", exc)
raise HTTPException(
status_code=409,
detail="Workspace name already exists for this repository",
) from exc
await session.refresh(workspace)
return {
"id": str(workspace.id),
"name": workspace.name,
"repo_id": str(workspace.repo_id),
"branch": workspace.branch,
"path": workspace.path,
"status": workspace.status,
"created_at": workspace.created_at.isoformat()
if workspace.created_at
else None,
}
@router.get("/")
async def list_workspaces(
project_id: uuid.UUID,
repo_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[dict]:
"""List workspaces for a repository, with instance counts."""
# Verify repo belongs to project and user
repo = await _get_repo(session, repo_id, project_id, user_id)
# Build subquery for instance counts
instance_count = (
select(func.count(ToolInstance.id))
.where(ToolInstance.workspace_id == Workspace.id)
.correlate(Workspace)
.scalar_subquery()
)
result = await session.execute(
select(
Workspace,
instance_count.label("instance_count"),
)
.where(Workspace.repo_id == repo_id)
.order_by(Workspace.created_at.desc())
)
rows = result.all()
return [
{
"id": str(ws.id),
"name": ws.name,
"repo_id": str(ws.repo_id),
"repo_name": repo.name,
"repo_ssh_key_id": str(repo.ssh_key_id) if repo.ssh_key_id else None,
"project_id": str(repo.project_id) if repo.project_id else "",
"project_name": repo.project.name if repo.project else "",
"user_id": str(ws.user_id),
"branch": ws.branch,
"path": ws.path,
"status": ws.status,
"last_sync_at": ws.last_sync_at.isoformat() if ws.last_sync_at else None,
"created_at": ws.created_at.isoformat() if ws.created_at else None,
"updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
"instance_count": count or 0,
}
for ws, count in rows
]
@router.post("/")
async def create_workspace(
project_id: uuid.UUID,
repo_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a new workspace by cloning a repository branch."""
repo = await _get_repo(session, repo_id, project_id, user_id)
name = data.get("name", "").strip()
branch = data.get("branch", "main").strip()
if not name:
raise HTTPException(status_code=400, detail="Workspace name is required")
if not branch:
raise HTTPException(status_code=400, detail="Branch is required")
manager = WorkspaceManager()
try:
workspace = await manager.create(repo, user_id, name, branch, session=session)
session.add(workspace)
await session.commit()
except HTTPException:
raise
except ValueError as exc:
await session.rollback()
logger.error("Failed to create workspace: %s", exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
await session.rollback()
logger.error("Failed to create workspace: %s", exc)
raise HTTPException(
status_code=409,
detail="Workspace name already exists for this repository",
) from exc
await session.refresh(workspace)
return {
"id": str(workspace.id),
"name": workspace.name,
"repo_id": str(workspace.repo_id),
"branch": workspace.branch,
"path": workspace.path,
"status": workspace.status,
"created_at": workspace.created_at.isoformat()
if workspace.created_at
else None,
}
@router.get("/{workspace_id}")
async def get_workspace_detail(
project_id: uuid.UUID,
repo_id: uuid.UUID,
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get workspace details."""
repo = await _get_repo(session, repo_id, project_id, user_id)
workspace = await _get_workspace(session, workspace_id, repo_id)
# Count instances
result = await session.execute(
select(func.count(ToolInstance.id)).where(
ToolInstance.workspace_id == workspace_id
)
)
instance_count = result.scalar() or 0
return {
"id": str(workspace.id),
"name": workspace.name,
"repo_id": str(workspace.repo_id),
"repo_name": repo.name,
"user_id": str(workspace.user_id),
"branch": workspace.branch,
"path": workspace.path,
"status": workspace.status,
"last_sync_at": workspace.last_sync_at.isoformat()
if workspace.last_sync_at
else None,
"created_at": workspace.created_at.isoformat()
if workspace.created_at
else None,
"updated_at": workspace.updated_at.isoformat()
if workspace.updated_at
else None,
"instance_count": instance_count,
}
@router.patch("/{workspace_id}")
async def update_workspace(
project_id: uuid.UUID,
repo_id: uuid.UUID,
workspace_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update workspace name or branch."""
await _get_repo(session, repo_id, project_id, user_id)
workspace = await _get_workspace(session, workspace_id, repo_id)
new_name = data.get("name", "").strip()
new_branch = data.get("branch", "").strip()
if new_name:
workspace.name = new_name
if new_branch:
workspace.branch = new_branch
try:
await session.commit()
except Exception as exc:
await session.rollback()
logger.error("Failed to update workspace: %s", exc)
raise HTTPException(
status_code=409,
detail="Workspace name already exists for this repository",
) from exc
return {
"id": str(workspace.id),
"name": workspace.name,
"branch": workspace.branch,
"status": workspace.status,
}
@router.delete("/{workspace_id}")
async def delete_workspace(
project_id: uuid.UUID,
repo_id: uuid.UUID,
workspace_id: uuid.UUID,
force: bool = Query(False),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Delete a workspace. Returns 409 if instances exist and force=False."""
await _get_repo(session, repo_id, project_id, user_id)
workspace = await _get_workspace(session, workspace_id, repo_id)
manager = WorkspaceManager()
try:
await manager.delete(workspace, force=force, session=session)
await session.commit()
except WorkspaceHasInstancesError as exc:
await session.rollback()
raise HTTPException(
status_code=409,
detail={
"message": "Workspace has running tool instances",
"instances": exc.instances,
},
) from exc
except Exception as exc:
await session.rollback()
logger.error("Failed to delete workspace: %s", exc)
raise HTTPException(
status_code=500, detail="Failed to delete workspace"
) from exc
return {"status": "deleted"}
@router.post("/{workspace_id}/sync")
async def sync_workspace(
project_id: uuid.UUID,
repo_id: uuid.UUID,
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Sync workspace with remote. Returns 409 if branch was deleted."""
await _get_repo(session, repo_id, project_id, user_id)
workspace = await _get_workspace(session, workspace_id, repo_id)
manager = WorkspaceManager()
result = await manager.sync(workspace, session=session)
if result.branch_deleted:
raise HTTPException(
status_code=409,
detail={
"message": f"Branch '{workspace.branch}' was deleted from remote",
"branch_deleted": True,
},
)
await session.commit()
return {
"branch_deleted": False,
"pulled": True,
"last_sync_at": workspace.last_sync_at.isoformat()
if workspace.last_sync_at
else None,
}
async def _get_repo(
session: AsyncSession,
repo_id: uuid.UUID,
project_id: uuid.UUID,
user_id: uuid.UUID,
) -> GitRepository:
"""Fetch and validate repository access."""
result = await session.execute(
select(GitRepository)
.where(
GitRepository.id == repo_id,
GitRepository.project_id == project_id,
)
.options(selectinload(GitRepository.project))
)
repo = result.scalar_one_or_none()
if not repo:
raise HTTPException(status_code=404, detail="Repository not found")
return repo
async def _get_workspace(
session: AsyncSession,
workspace_id: uuid.UUID,
repo_id: uuid.UUID,
) -> Workspace:
"""Fetch and validate workspace."""
result = await session.execute(
select(Workspace).where(
Workspace.id == workspace_id,
Workspace.repo_id == repo_id,
)
)
workspace = result.scalar_one_or_none()
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
return workspace
+1 -1
View File
@@ -10,7 +10,7 @@ from collections.abc import Callable
from fastapi import Request, Response from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from src.services.shared.correlation import get_correlation_id from src.services.correlation import get_correlation_id
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+22 -38
View File
@@ -7,42 +7,35 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from src.api.config import config_profiles_router, user_config_router from src.api.auth import router as auth_router
from src.api.project import git_repositories_router, projects_router from src.api.dashboard import router as dashboard_router
from src.api.system import ( from src.api.events import router as events_router
dashboard_router, from src.api.git_repositories import router as git_repositories_router
events_router, from src.api.health import router as health_router
health_router, from src.api.projects import router as projects_router
instance_proxy_router, from src.api.ssh_keys import router as ssh_keys_router
notifications_router, from src.api.terminal import router as terminal_router
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 import ( from src.api.tool_definitions import router as tool_definitions_router
sessions_router, from src.api.tool_instances import router as tool_instances_router
tool_definitions_router, from src.api.tool_instances import sessions_router
tool_instances_router, from src.api.tool_types import router as tool_types_router
tool_types_router, from src.api.notifications import router as notifications_router
) from src.api.user_config import router as user_config_router
from src.api.user import auth_router, ssh_keys_router, users_router from src.api.users import router as 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.config import Settings
from src.models import Notification # noqa: F401 Alembic model discovery from src.models.notification import Notification # noqa: F401 Alembic model discovery
from src.models import TerminalSessionModel # noqa: F401 Alembic model discovery from src.models.terminal_session import TerminalSessionModel # noqa: F401 Alembic model discovery
from src.database import init_database from src.database import init_database
from src.logging_config import ( from src.logging_config import (
ExceptionLoggingMiddleware, ExceptionLoggingMiddleware,
RequestLoggingMiddleware, RequestLoggingMiddleware,
configure_logging, configure_logging,
) )
from src.seeds.builtin_tool_types import seed_builtin_tool_types from src.services.correlation import CorrelationIdMiddleware
from src.services.instance import InstanceEventBus, HealthMonitor from src.services.event_bus import InstanceEventBus
from src.services.shared import CorrelationIdMiddleware from src.services.health_monitor import HealthMonitor
# Configure logging early # Configure logging early
log_level = os.getenv("LOG_LEVEL", "INFO").upper() log_level = os.getenv("LOG_LEVEL", "INFO").upper()
@@ -138,10 +131,6 @@ async def on_startup():
_health_monitor.start() _health_monitor.start()
logger.info("Health monitor started") 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.") logger.info("Startup complete.")
@@ -170,9 +159,4 @@ app.include_router(instance_proxy_router)
app.include_router(terminal_router) app.include_router(terminal_router)
app.include_router(events_router) app.include_router(events_router)
app.include_router(notifications_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") app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
+13 -15
View File
@@ -1,18 +1,17 @@
from src.models.base import Base from src.models.base import Base
from src.models.config.config_profile import ConfigProfile, ConfigProfileInclude from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.project.git_repository import GitRepository from src.models.git_repository import GitRepository
from src.models.project.project import Project from src.models.health_check import HealthCheck
from src.models.project.workspace import Workspace from src.models.instance_event import InstanceEvent
from src.models.system.health_check import HealthCheck from src.models.notification import Notification
from src.models.system.instance_event import InstanceEvent from src.models.project import Project
from src.models.system.notification import Notification from src.models.ssh_key import SSHKey
from src.models.system.terminal_session import TerminalSessionModel from src.models.terminal_session import TerminalSessionModel
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest from src.models.tool_definition_manifest import ToolDefinitionManifest
from src.models.tool.tool_instance import ToolInstance from src.models.tool_instance import ToolInstance
from src.models.tool.tool_type import ToolType from src.models.tool_type import ToolType
from src.models.user.ssh_key import SSHKey from src.models.user import User
from src.models.user.user import User from src.models.user_config import UserConfig
from src.models.user.user_config import UserConfig
__all__ = [ __all__ = [
"Base", "Base",
@@ -30,5 +29,4 @@ __all__ = [
"ToolType", "ToolType",
"User", "User",
"UserConfig", "UserConfig",
"Workspace",
] ]
-5
View File
@@ -1,5 +0,0 @@
"""Config models module."""
from src.models.config.config_profile import ConfigProfile, ConfigProfileInclude
__all__ = ["ConfigProfile", "ConfigProfileInclude"]
@@ -1,15 +1,7 @@
import uuid import uuid
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import ( from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean
Boolean,
ForeignKey,
JSON,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy import Uuid as UUID from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -17,15 +9,12 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.project import Project from src.models.project import Project
from src.models import ToolType from src.models.tool_type import ToolType
from src.models.user import User from src.models.user import User
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base): class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_profiles" __tablename__ = "config_profiles"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
)
user_id: Mapped[uuid.UUID] = mapped_column( user_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
@@ -10,7 +10,7 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.project import Project from src.models.project import Project
from src.models import SSHKey from src.models.ssh_key import SSHKey
from src.models.user import User 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 from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models import GitRepository from src.models.git_repository import GitRepository
from src.models import SSHKey from src.models.ssh_key import SSHKey
from src.models.user import User from src.models.user import User
-7
View File
@@ -1,7 +0,0 @@
"""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"]
-50
View File
@@ -1,50 +0,0 @@
"""Workspace model for persistent writable repo clones."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin
if TYPE_CHECKING:
from src.models import GitRepository
from src.models.user import User
class Workspace(Base, TimestampMixin):
"""A persistent, writable local clone of a Git repository.
Users create workspaces explicitly, then start tool instances on them.
Multiple tool instances can share the same workspace.
"""
__tablename__ = "workspaces"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(255), nullable=False)
repo_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("git_repositories.id", ondelete="CASCADE"),
nullable=False,
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
branch: Mapped[str] = mapped_column(String(255), nullable=False, default="main")
path: Mapped[str] = mapped_column(String(2048), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="ready")
last_sync_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
__table_args__ = (
UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"),
)
repository: Mapped[GitRepository] = relationship("GitRepository")
owner: Mapped[User] = relationship("User")
-8
View File
@@ -1,8 +0,0 @@
"""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
@@ -1,7 +0,0 @@
"""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,12 +9,11 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models import ConfigProfile from src.models.config_profile import ConfigProfile
from src.models import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models import ToolType from src.models.tool_type import ToolType
from src.models.user import User from src.models.user import User
from src.models import Workspace
class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base): class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
@@ -61,12 +60,8 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True 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() tool_type: Mapped["ToolType"] = relationship()
workspace: Mapped["Workspace | None"] = relationship()
repository: Mapped["GitRepository"] = relationship() repository: Mapped["GitRepository"] = relationship()
project: Mapped["Project"] = relationship() project: Mapped["Project"] = relationship()
owner: Mapped["User"] = relationship() owner: Mapped["User"] = relationship()
@@ -6,9 +6,9 @@ from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.tool_definition_manifest import ToolDefinitionManifest
from src.models.user import User from src.models.user import User
@@ -7,8 +7,8 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.project import Project from src.models.project import Project
from src.models import SSHKey from src.models.ssh_key import SSHKey
from src.models import UserConfig from src.models.user_config import UserConfig
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
-7
View File
@@ -1,7 +0,0 @@
"""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"]
-51
View File
@@ -1,51 +0,0 @@
import uuid
from typing import TYPE_CHECKING
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
if TYPE_CHECKING:
from src.models.user import User
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: 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")
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:
if value is not None:
self.config["default_profile_id"] = str(value)
elif "default_profile_id" in self.config:
del self.config["default_profile_id"]
@property
def default_profiles(self) -> dict[str, str]:
"""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:
self.config["default_profiles"] = value
+20
View File
@@ -0,0 +1,20 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey
from sqlalchemy import JSON, 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.user import User
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: Mapped["User"] = relationship(back_populates="user_config")
-27
View File
@@ -1,27 +0,0 @@
"""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",
]
@@ -1,280 +0,0 @@
"""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
-41
View File
@@ -1,41 +0,0 @@
"""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",
]
@@ -1,47 +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
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
-29
View File
@@ -1,29 +0,0 @@
"""Project request/response schemas."""
import uuid
from pydantic import BaseModel, ConfigDict
class ProjectCreate(BaseModel):
name: str
description: str | None = None
class ProjectUpdate(BaseModel):
name: str | None = None
description: str | None = None
class ProjectResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
owner_id: uuid.UUID
default_ssh_key_id: uuid.UUID | None
class SetDefaultSSHKeyRequest(BaseModel):
ssh_key_id: uuid.UUID
-36
View File
@@ -1,36 +0,0 @@
"""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
-17
View File
@@ -1,17 +0,0 @@
"""System schemas module."""
from src.schemas.system.health import (
DatabaseHealth,
DatabaseHealthResponse,
DiskHealth,
HealthChecks,
HealthResponse,
)
__all__ = [
"DatabaseHealth",
"DatabaseHealthResponse",
"DiskHealth",
"HealthChecks",
"HealthResponse",
]
-50
View File
@@ -1,50 +0,0 @@
"""Health check response schemas."""
from pydantic import BaseModel, Field
class DatabaseHealth(BaseModel):
"""Database health check result."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(
description="Query response time in milliseconds", examples=[5.2]
)
class DiskHealth(BaseModel):
"""Disk space health check result."""
status: str = Field(description="Disk health status", examples=["healthy"])
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
class HealthChecks(BaseModel):
"""Individual health checks."""
database: DatabaseHealth | None = None
disk: DiskHealth | None = None
class HealthResponse(BaseModel):
"""Overall health check response."""
status: str = Field(description="Overall health status", examples=["healthy"])
timestamp: str = Field(
description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"]
)
version: str = Field(description="API version", examples=["0.1.0"])
checks: HealthChecks = Field(description="Individual health checks")
uptime_seconds: float = Field(
description="Server uptime in seconds", examples=[3600.0]
)
class DatabaseHealthResponse(BaseModel):
"""Database-specific health check response."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(
description="Query response time in milliseconds", examples=[5.2]
)
-18
View File
@@ -1,18 +0,0 @@
"""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",
]
@@ -1,45 +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"
)
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"
)
-230
View File
@@ -1,230 +0,0 @@
"""Tool type request/response schemas."""
import uuid
from datetime import datetime
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 = 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"
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", "manifest"):
raise ValueError(
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
)
return v
@field_validator("compose_template")
@classmethod
def validate_compose_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "compose":
return v
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")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "dockerfile":
return v
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")
return v
@field_validator("required_variables")
@classmethod
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
validate_required_variables(template, v)
return v
@model_validator(mode="after")
def validate_templates(self) -> "ToolTypeCreate":
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
class ToolTypeUpdate(BaseModel):
display_name: str | None = None
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
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", "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")
@classmethod
def validate_compose_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
validate_compose_yaml(v)
return v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
class ToolTypeResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
display_name: str
description: str | None
category: 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]
created_by_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
class ToolTypeValidateRequest(BaseModel):
definition_type: str
compose_template: str | None = None
dockerfile_template: str | None = None
-11
View File
@@ -1,11 +0,0 @@
"""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",
]
-19
View File
@@ -1,19 +0,0 @@
"""User response schemas."""
import uuid
from pydantic import BaseModel, ConfigDict
class UserProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name: str
avatar_url: str | None
class UserProfileUpdate(BaseModel):
name: str | None = None
email: str | None = None
-25
View File
@@ -1,25 +0,0 @@
"""User config response schemas."""
from pydantic import BaseModel, ConfigDict
class UserConfigResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
default_editor: str | None = None
theme: str = "system"
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):
default_editor: str | None = None
theme: str | None = None
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
@@ -1 +0,0 @@
"""Database seeding utilities."""
-168
View File
@@ -1,168 +0,0 @@
"""Seed built-in tool types into the database."""
import logging
from sqlalchemy import select, text
from src.database import SessionLocal
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."""
try:
result = await session.execute(
text(
"""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = :table_name
)
"""
),
{"table_name": table_name},
)
return result.scalar() or False
except Exception:
return False
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"):
logger.warning(
"tool_types table does not exist. Skipping seeding. "
"Migrations may not have run yet."
)
return
builtin_types = [
{
"name": "code-server",
"display_name": "VS Code Server",
"description": "VS Code running in the browser via code-server",
"category": "editor",
"interface_type": "web",
"compose_template": """version: "3.8"
services:
code-server:
image: lscr.io/linuxserver/code-server:latest
container_name: {{TOOL_NAME}}
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- {{REPO_PATH}}:/config/workspace
ports:
- "8443:8443"
restart: unless-stopped""",
"default_port": 8443,
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "jupyter-notebook",
"display_name": "Jupyter Notebook",
"description": "Jupyter Lab for interactive development",
"category": "notebook",
"interface_type": "web",
"default_port": 8888,
"compose_template": """version: "3.8"
services:
jupyter:
image: jupyter/scipy-notebook:latest
container_name: {{TOOL_NAME}}
environment:
- JUPYTER_ENABLE_LAB=yes
volumes:
- {{REPO_PATH}}:/home/jovyan/work
ports:
- "8888:8888"
restart: unless-stopped""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "opencode",
"display_name": "OpenCode",
"description": "AI coding assistant - run opencode in terminal",
"category": "ai-assistant",
"interface_type": "terminal",
"default_port": 3000,
"compose_template": """version: "3.8"
services:
opencode:
image: node:20-slim
container_name: {{TOOL_NAME}}
working_dir: /workspace
environment:
- HOME=/tmp
volumes:
- {{REPO_PATH}}:/workspace
- opencode_home:/tmp
ports:
- "3000:3000"
command: >
sh -c "set -x &&
apt-get update && apt-get install -y git ca-certificates &&
echo 'Installing opencode...' &&
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
which opencode || echo 'ERROR: opencode not in PATH' &&
npm bin -g &&
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
echo 'export PATH=\"$(npm bin -g):\\$PATH\"' >> /root/.bashrc &&
echo 'cd /workspace' >> /root/.bashrc &&
echo 'OpenCode installation complete' &&
cd /workspace &&
exec tail -f /dev/null"
stdin_open: true
tty: true
restart: unless-stopped
volumes:
opencode_home:""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
]
for tool_data in builtin_types:
existing = await session.scalar(
select(ToolType).where(ToolType.name == tool_data["name"])
)
if not existing:
tool_type = ToolType(
name=tool_data["name"],
display_name=tool_data["display_name"],
description=tool_data["description"],
category=tool_data["category"],
interface_type=tool_data["interface_type"],
definition_type="compose",
compose_template=tool_data["compose_template"],
required_variables=tool_data["required_variables"],
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"])
else:
# Update existing built-in tool types to reflect code changes
existing.display_name = tool_data["display_name"]
existing.description = tool_data["description"]
existing.category = tool_data["category"]
existing.interface_type = tool_data["interface_type"]
existing.definition_type = "compose"
existing.compose_template = tool_data["compose_template"]
existing.required_variables = tool_data["required_variables"]
existing.default_port = tool_data.get("default_port", 0)
logger.info("Updated built-in tool type: %s", tool_data["name"])
await session.commit()
logger.info("Built-in tool types seeded successfully.")
-25
View File
@@ -1,25 +0,0 @@
"""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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.models import ConfigProfile, ConfigProfileInclude from src.models.config_profile import ConfigProfile, ConfigProfileInclude
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+717
View File
@@ -0,0 +1,717 @@
"""Docker service for managing tool instances."""
import logging
import os
import re
import subprocess
import time
from collections import Counter
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
"""Sort volume strings so parent paths come before child paths.
Docker Compose mounts volumes in array order. A later mount at a parent
path hides earlier mounts at child paths. By sorting shallow paths first
and deep paths last, deeper (more specific) mounts overlay correctly.
Volume format: source:target or source:target:type
Args:
volumes: List of Docker volume mount strings.
Returns:
Sorted list with parent paths before child paths.
"""
def _target_depth(vol: str) -> int:
parts = vol.split(":")
if len(parts) < 2:
return 0
target = parts[1].rstrip("/")
if not target or target == "/":
return 0
return target.count("/")
# Detect duplicate targets and warn
targets = []
for vol in volumes:
parts = vol.split(":")
targets.append(parts[1] if len(parts) > 1 else "")
dupes = [t for t, c in Counter(targets).items() if c > 1]
if dupes:
logger.warning("Duplicate mount targets detected: %s", dupes)
# Stable sort: parent paths first, child paths last
return sorted(volumes, key=_target_depth)
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
"""Render a Docker Compose template with variable substitution.
Args:
template: The compose template string
variables: Dictionary of variable names to values
Returns:
Rendered compose file content
"""
result = template
for key, value in variables.items():
placeholder = f"{{{{{key}}}}}"
result = result.replace(placeholder, str(value))
return result
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
"""Create and return the instance directory path.
Args:
instance_id: Unique instance identifier
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
Returns:
Absolute path to instance directory
"""
if base_path is None:
from src.config import Settings
base_path = Settings().instance_base_path
instance_dir = Path(base_path) / instance_id
instance_dir.mkdir(parents=True, exist_ok=True)
return str(instance_dir.absolute())
def write_compose_file(instance_dir: str, content: str) -> str:
"""Write the rendered compose file to the instance directory.
Args:
instance_dir: Path to instance directory
content: Rendered compose content
Returns:
Path to the compose file
"""
compose_path = Path(instance_dir) / "docker-compose.yml"
compose_path.write_text(content)
return str(compose_path)
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
"""Write environment variables to a .env file.
Args:
instance_dir: Path to instance directory
env_vars: Dictionary of env var names to values
Returns:
Path to the env file
"""
env_path = Path(instance_dir) / ".env"
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
env_path.write_text("\n".join(lines) + "\n")
return str(env_path)
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
"""Write config files to the instance directory.
Args:
instance_dir: Path to instance directory
files: Dictionary of file paths (relative to instance dir) to content
"""
instance_path = Path(instance_dir)
for file_path, content in files.items():
# Ensure the path is within the instance directory (security)
full_path = instance_path / file_path
try:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
raise ValueError(f"File path '{file_path}' escapes instance directory")
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
def execute_compose_command(
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
) -> tuple[int, str, str]:
"""Execute a docker compose command.
Args:
compose_path: Path to docker-compose.yml
action: The compose action (up, down, start, stop, restart)
timeout: Command timeout in seconds
env_file: Optional path to .env file for environment variables
Returns:
Tuple of (returncode, stdout, stderr)
"""
instance_dir = Path(compose_path).parent
cmd = ["docker", "compose", "-f", compose_path]
if env_file:
cmd.extend(["--env-file", env_file])
if action == "up":
cmd.extend(["up", "-d", "--force-recreate"])
elif action == "down":
cmd.extend(["down", "-v"])
elif action in ("start", "stop", "restart"):
cmd.append(action)
else:
raise ValueError(f"Unknown compose action: {action}")
result = subprocess.run(
cmd,
cwd=str(instance_dir),
capture_output=True,
text=True,
timeout=timeout,
)
return result.returncode, result.stdout, result.stderr
def get_container_id(instance_name: str) -> str | None:
"""Get the container ID for a compose service.
Searches all containers including stopped/exited ones.
Args:
instance_name: The service name in compose
Returns:
Container ID or None if not found
"""
# Docker container names are lowercase internally; normalize to ensure match
result = subprocess.run(
["docker", "ps", "-a", "-q", "--filter", 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 None
def get_container_name(instance_name: str) -> str | None:
"""Get the full container name for a compose service.
Searches all containers including stopped/exited ones.
Args:
instance_name: The service name in compose
Returns:
Container name or None if not found
"""
# Docker container names are lowercase internally; normalize to ensure match
result = subprocess.run(
[
"docker",
"ps",
"-a",
"--format",
"{{.Names}}",
"--filter",
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 None
def connect_container_to_network(
container_name: str, network_name: str = "backend"
) -> bool:
"""Connect a Docker container to an existing network.
Args:
container_name: Name or ID of the container
network_name: Name of the Docker network (default: backend)
Returns:
True if successful, False otherwise
"""
result = subprocess.run(
["docker", "network", "connect", network_name, container_name],
capture_output=True,
text=True,
)
return result.returncode == 0
def get_container_status(container_id: str) -> dict[str, Any]:
"""Get the status of a Docker container.
Args:
container_id: Docker container ID
Returns:
Dict with 'status' (running, exited, restarting, not_found),
'exit_code' (int or None), and 'health' (health status or None)
"""
result = subprocess.run(
[
"docker",
"inspect",
"-f",
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
container_id,
],
capture_output=True,
text=True,
)
if result.returncode != 0:
return {"status": "not_found", "exit_code": None, "health": None}
parts = result.stdout.strip().split("|")
status = parts[0] if parts else "unknown"
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
return {"status": status, "exit_code": exit_code, "health": health}
def wait_for_container_running(
container_id: str, timeout: int = 30, interval: float = 2.0
) -> dict[str, Any]:
"""Wait for a container to reach the running state.
Polls docker inspect until the container status is "running" or timeout.
Args:
container_id: Docker container ID
timeout: Maximum seconds to wait
interval: Seconds between polls
Returns:
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
and 'waited_seconds' (float)
"""
start_time = time.time()
while time.time() - start_time < timeout:
info = get_container_status(container_id)
if info["status"] == "running":
return {
"success": True,
"status": "running",
"exit_code": None,
"waited_seconds": time.time() - start_time,
}
if info["status"] == "exited":
return {
"success": False,
"status": "exited",
"exit_code": info["exit_code"],
"waited_seconds": time.time() - start_time,
}
if info["status"] == "not_found":
return {
"success": False,
"status": "not_found",
"exit_code": None,
"waited_seconds": time.time() - start_time,
}
time.sleep(interval)
# Timeout reached
info = get_container_status(container_id)
return {
"success": False,
"status": info["status"],
"exit_code": info["exit_code"],
"waited_seconds": time.time() - start_time,
}
def get_container_logs(container_id: str, tail: int = 100) -> str:
"""Get the logs of a Docker container.
Args:
container_id: Docker container ID
tail: Number of lines to return
Returns:
Container logs
"""
result = subprocess.run(
["docker", "logs", "--tail", str(tail), container_id],
capture_output=True,
text=True,
)
if result.returncode == 0:
return result.stdout
return f"Failed to get logs: {result.stderr}"
def find_free_port(start: int = 10000, end: int = 20000) -> int:
"""Find a free TCP port in the given range.
Args:
start: Start of port range
end: End of port range
Returns:
Free port number
"""
import socket
for port in range(start, end):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
if s.connect_ex(("localhost", port)) != 0:
return port
raise RuntimeError(f"No free port found in range {start}-{end}")
def _check_app_binding(container_name: str, port: int) -> dict[str, str | bool]:
"""Diagnose whether the app is bound to 127.0.0.1 or 0.0.0.0.
Checks from both inside the container (localhost) and outside
(via Docker network) to detect binding issues.
Returns:
Dict with 'internal_ok', 'external_ok', 'internal_status',
'external_status', and 'diagnosis'.
"""
import subprocess
result: dict[str, Any] = {
"internal_ok": False,
"external_ok": False,
"internal_status": None,
"external_status": None,
"diagnosis": "unknown",
}
# Check from inside the container (loopback)
internal = subprocess.run(
[
"docker",
"exec",
container_name,
"sh",
"-c",
f"curl -s -o /dev/null -w '%{{http_code}}' http://localhost:{port}",
],
capture_output=True,
text=True,
timeout=5,
)
if internal.returncode == 0:
try:
result["internal_status"] = int(internal.stdout.strip())
result["internal_ok"] = result["internal_status"] > 0
except ValueError:
pass
# Check from outside the container (Docker network)
external = subprocess.run(
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
f"http://{container_name}:{port}",
],
capture_output=True,
text=True,
timeout=5,
)
if external.returncode == 0:
try:
result["external_status"] = int(external.stdout.strip())
result["external_ok"] = result["external_status"] > 0
except ValueError:
pass
# Diagnose binding issue
if result["internal_ok"] and not result["external_ok"]:
result["diagnosis"] = (
f"App appears to be bound to 127.0.0.1:{port} inside the container. "
f"It must bind to 0.0.0.0:{port} to be accessible from the tunnel."
)
elif result["internal_ok"] and result["external_ok"]:
result["diagnosis"] = "App is accessible on both interfaces."
elif not result["internal_ok"] and not result["external_ok"]:
result["diagnosis"] = f"App is not responding on port {port} at all."
else:
result["diagnosis"] = "Unexpected binding state."
return result
def start_cloudflared_tunnel(
container_name: str, port: int, timeout: int = 30
) -> dict[str, str]:
"""Start a temporary Cloudflare tunnel for a container.
Uses 'cloudflared tunnel --url' to create a temporary tunnel
with a random trycloudflare.com URL.
Args:
container_name: Name of the Docker container to tunnel to
port: Port number the container listens on
timeout: Maximum seconds to wait for tunnel URL
Returns:
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
"""
import subprocess
import logging
logger = logging.getLogger(__name__)
# First verify the container is accessible from the Docker network
logger.info("Checking connectivity to %s:%d...", container_name, port)
accessible = False
last_status = None
for attempt in range(30): # 30 attempts × 1s = 30s max wait for app startup
check = subprocess.run(
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--max-time",
"3",
f"http://{container_name}:{port}",
],
capture_output=True,
text=True,
timeout=5,
)
status_str = check.stdout.strip()
logger.info(
"Connectivity check %d/%d: http_code=%s (rc=%d)",
attempt + 1,
30,
status_str,
check.returncode,
)
try:
last_status = int(status_str)
# Accept 2xx, 3xx, 401, 403 as "app is listening"
if last_status in (401, 403) or 200 <= last_status < 400:
accessible = True
logger.info(
"App on %s:%d is ready (HTTP %d)",
container_name,
port,
last_status,
)
break
except ValueError:
pass
if check.returncode != 0:
logger.debug(
"curl failed: stderr=%s", check.stderr.strip() if check.stderr else ""
)
time.sleep(1)
if not accessible:
logger.warning(
"Container %s:%d not responding after 30s (last status: %s). "
"Running binding diagnostics...",
container_name,
port,
last_status,
)
diagnosis = _check_app_binding(container_name, port)
logger.warning(
"Binding diagnosis: internal=%s (HTTP %s), external=%s (HTTP %s). %s",
diagnosis["internal_ok"],
diagnosis["internal_status"],
diagnosis["external_ok"],
diagnosis["external_status"],
diagnosis["diagnosis"],
)
# Run cloudflared in background, capture output
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
proc = subprocess.Popen(
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
# Wait for the URL to appear in output
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
start_time = time.time()
url = None
if proc.stdout is None:
proc.terminate()
proc.wait(timeout=5)
raise RuntimeError("Failed to capture cloudflared output")
while time.time() - start_time < timeout:
# Read available output
import select
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
if readable:
line = proc.stdout.readline()
if line:
match = url_pattern.search(line)
if match:
url = match.group(0)
break
if not url:
proc.terminate()
proc.wait(timeout=5)
raise RuntimeError(
f"Failed to get tunnel URL within {timeout}s. "
f"cloudflared output may contain errors."
)
return {"url": url, "pid": str(proc.pid)}
def stop_cloudflared_tunnel(pid: str) -> None:
"""Stop a cloudflared tunnel process.
Args:
pid: Process ID of the cloudflared tunnel
"""
import signal
try:
os.kill(int(pid), signal.SIGTERM)
except ProcessLookupError:
pass # Already stopped
def recreate_tunnel(
container_name: str, port: int, old_pid: str | None = None
) -> dict[str, str]:
"""Recreate a temporary Cloudflare tunnel.
Stops the old tunnel (if pid provided) and starts a new one.
Args:
container_name: Name of the Docker container to tunnel to
port: Port number the container listens on
old_pid: Optional PID of the old tunnel process to stop
Returns:
Dict with 'url' and 'pid' for the new tunnel
"""
if old_pid:
stop_cloudflared_tunnel(old_pid)
return start_cloudflared_tunnel(container_name, port)
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
"""Check if a tunnel URL is healthy with smart error classification.
Args:
url: The tunnel URL to check
timeout: Request timeout in seconds
Returns:
Dict with 'tunnel_status' (healthy, unreachable, error_response, not_applicable),
'status_code' (int or None), 'healthy' (bool), and 'error' (str or None)
"""
import subprocess
try:
result = subprocess.run(
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--max-time",
str(timeout),
url,
],
capture_output=True,
text=True,
timeout=timeout + 5,
)
status_code = int(result.stdout.strip())
if 200 <= status_code < 400:
return {
"tunnel_status": "healthy",
"status_code": status_code,
"healthy": True,
"error": None,
}
elif status_code in (502, 503, 504):
# Application error, not tunnel error
return {
"tunnel_status": "error_response",
"status_code": status_code,
"healthy": False,
"error": f"Application returned HTTP {status_code}",
}
else:
return {
"tunnel_status": "error_response",
"status_code": status_code,
"healthy": False,
"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 e:
error_str = str(e).lower()
# Classify connection errors
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: {e}",
}
return {
"tunnel_status": "unreachable",
"status_code": None,
"healthy": False,
"error": str(e),
}
-55
View File
@@ -1,55 +0,0 @@
"""Docker services package for container and compose operations."""
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 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 src.services.docker.tunnel import (
check_tunnel_health,
recreate_tunnel,
start_tunnel,
stop_tunnel,
)
__all__ = [
"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",
]
-120
View File
@@ -1,120 +0,0 @@
"""Docker Compose file generation and manipulation."""
import logging
import subprocess
from collections import Counter
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
"""Sort volume strings so parent paths come before child paths.
Docker Compose mounts volumes in array order. A later mount at a parent
path hides earlier mounts at child paths. By sorting shallow paths first
and deep paths last, deeper (more specific) mounts overlay correctly.
Volume format: source:target or source:target:type
Args:
volumes: List of Docker volume mount strings.
Returns:
Sorted list with parent paths before child paths.
"""
def _target_depth(vol: str) -> int:
parts = vol.split(":")
if len(parts) < 2:
return 0
target = parts[1].rstrip("/")
if not target or target == "/":
return 0
return target.count("/")
# Detect duplicate targets and warn
targets = []
for vol in volumes:
parts = vol.split(":")
targets.append(parts[1] if len(parts) > 1 else "")
dupes = [t for t, c in Counter(targets).items() if c > 1]
if dupes:
logger.warning("Duplicate mount targets detected: %s", dupes)
# Stable sort: parent paths first, child paths last
return sorted(volumes, key=_target_depth)
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
"""Render a Docker Compose template with variable substitution.
Args:
template: The compose template string
variables: Dictionary of variable names to values
Returns:
Rendered compose file content
"""
result = template
for key, value in variables.items():
placeholder = f"{{{{{key}}}}}"
result = result.replace(placeholder, str(value))
return result
def write_compose_file(instance_dir: str, content: str) -> str:
"""Write the rendered compose file to the instance directory.
Args:
instance_dir: Path to instance directory
content: Rendered compose content
Returns:
Path to the compose file
"""
compose_path = Path(instance_dir) / "docker-compose.yml"
compose_path.write_text(content)
return str(compose_path)
def execute_compose_command(
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
) -> tuple[int, str, str]:
"""Execute a docker compose command.
Args:
compose_path: Path to docker-compose.yml
action: The compose action (up, down, start, stop, restart)
timeout: Command timeout in seconds
env_file: Optional path to .env file for environment variables
Returns:
Tuple of (returncode, stdout, stderr)
"""
instance_dir = Path(compose_path).parent
cmd = ["docker", "compose", "-f", compose_path]
if env_file:
cmd.extend(["--env-file", env_file])
if action == "up":
cmd.extend(["up", "-d", "--force-recreate"])
elif action == "down":
cmd.extend(["down", "-v"])
elif action in ("start", "stop", "restart"):
cmd.append(action)
else:
raise ValueError(f"Unknown compose action: {action}")
result = subprocess.run(
cmd,
cwd=str(instance_dir),
capture_output=True,
text=True,
timeout=timeout,
)
return result.returncode, result.stdout, result.stderr
@@ -1,61 +0,0 @@
"""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.
Args:
instance_dir: Path to instance directory
files: Dictionary of file paths (relative to instance dir) to content
"""
instance_path = Path(instance_dir)
for file_path, content in files.items():
# Ensure the path is within the instance directory (security)
full_path = instance_path / file_path
try:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
raise ValueError(f"File path '{file_path}' escapes instance directory")
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
-316
View File
@@ -1,316 +0,0 @@
"""Docker container runtime queries and network management."""
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 expected container name.
Returns:
Container ID or None if not found.
"""
expected = instance_name.lower()
# Fast path: exact match via docker inspect
result = subprocess.run(
["docker", "inspect", "-f", "{{.Id}}", expected],
capture_output=True,
text=True,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
# Fallback: list all containers and do case-insensitive exact match
ps_result = subprocess.run(
["docker", "ps", "-a", "--format", "{{.Names}}\t{{.ID}}"],
capture_output=True,
text=True,
)
if ps_result.returncode == 0:
for line in ps_result.stdout.strip().splitlines():
parts = line.split("\t")
if len(parts) == 2:
name, cid = parts
if name.lower() == expected:
return cid
return None
def get_container_name(instance_name: str) -> str | None:
"""Get the full container name for a compose service.
Uses exact name matching via docker inspect to avoid substring collisions.
Args:
instance_name: The exact container name (case-insensitive for Docker).
Returns:
Container name or None if not found.
"""
result = subprocess.run(
["docker", "inspect", "-f", "{{.Name}}", instance_name.lower()],
capture_output=True,
text=True,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().lstrip("/")
return None
def get_backend_network_name() -> str:
"""Auto-detect the actual Docker network name for the backend network.
Docker Compose prefixes network names with the project directory name
(e.g. 'headquarter_backend' instead of 'backend'). We inspect the API
container itself to find the real network name it's connected to.
Returns:
The actual Docker network name, or 'backend' as fallback.
"""
# Try to find the API container by its known name
api_container = "hq-api"
result = subprocess.run(
[
"docker",
"inspect",
"-f",
"{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}",
api_container,
],
capture_output=True,
text=True,
)
if result.returncode == 0 and result.stdout.strip():
networks = result.stdout.strip().split()
for net in networks:
if "backend" in net.lower():
return net
# API container is on some network — return the first one
return networks[0]
return "backend"
def connect_container_to_network(
container_name: str, network_name: str | None = None
) -> bool:
"""Connect a Docker container to an existing network.
Args:
container_name: Name or ID of the container
network_name: Name of the Docker network. If None, auto-detects
from the API container's own network membership.
Returns:
True if successful, False otherwise
"""
if network_name is None:
network_name = get_backend_network_name()
result = subprocess.run(
["docker", "network", "connect", network_name, container_name],
capture_output=True,
text=True,
)
return result.returncode == 0
def get_container_ip_on_network(
container_id: str, network_name: str | None = None
) -> str | None:
"""Get a container's IP address on a specific Docker network.
Args:
container_id: Docker container ID or name.
network_name: Network name. If None, auto-detects from the API container.
Returns:
IP address string, or None if the container is not on that network.
"""
if network_name is None:
network_name = get_backend_network_name()
result = subprocess.run(
[
"docker",
"inspect",
"-f",
f"{{{{.NetworkSettings.Networks.{network_name}.IPAddress}}}}",
container_id,
],
capture_output=True,
text=True,
)
if result.returncode == 0:
ip = result.stdout.strip()
if ip and ip != "<no value>":
return ip
return None
def is_container_on_network(container_id: str, network_name: str | None = None) -> bool:
"""Check whether a container is already attached to a Docker network.
Args:
container_id: Docker container ID or name.
network_name: Network name. If None, auto-detects from the API container.
Returns:
True if the container is on the network.
"""
if network_name is None:
network_name = get_backend_network_name()
result = subprocess.run(
[
"docker",
"inspect",
"-f",
f"{{{{.NetworkSettings.Networks.{network_name}}}}}",
container_id,
],
capture_output=True,
text=True,
)
return result.returncode == 0 and "<no value>" not in result.stdout
def get_container_status(container_id: str) -> dict[str, Any]:
"""Get the status of a Docker container.
Args:
container_id: Docker container ID
Returns:
Dict with 'status' (running, exited, restarting, not_found),
'exit_code' (int or None), and 'health' (health status or None)
"""
result = subprocess.run(
[
"docker",
"inspect",
"-f",
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
container_id,
],
capture_output=True,
text=True,
)
if result.returncode != 0:
return {"status": "not_found", "exit_code": None, "health": None}
parts = result.stdout.strip().split("|")
status = parts[0] if parts else "unknown"
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
return {"status": status, "exit_code": exit_code, "health": health}
def wait_for_container_running(
container_id: str, timeout: int = 30, interval: float = 2.0
) -> dict[str, Any]:
"""Wait for a container to reach the running state.
Polls docker inspect until the container status is "running" or timeout.
Args:
container_id: Docker container ID
timeout: Maximum seconds to wait
interval: Seconds between polls
Returns:
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
and 'waited_seconds' (float)
"""
start_time = time.time()
while time.time() - start_time < timeout:
info = get_container_status(container_id)
if info["status"] == "running":
return {
"success": True,
"status": "running",
"exit_code": None,
"waited_seconds": time.time() - start_time,
}
if info["status"] == "exited":
return {
"success": False,
"status": "exited",
"exit_code": info["exit_code"],
"waited_seconds": time.time() - start_time,
}
if info["status"] == "not_found":
return {
"success": False,
"status": "not_found",
"exit_code": None,
"waited_seconds": time.time() - start_time,
}
time.sleep(interval)
# Timeout reached
info = get_container_status(container_id)
return {
"success": False,
"status": info["status"],
"exit_code": info["exit_code"],
"waited_seconds": time.time() - start_time,
}
def get_container_logs(container_id: str, tail: int = 100) -> str:
"""Get the logs of a Docker container.
Args:
container_id: Docker container ID
tail: Number of lines to return
Returns:
Container logs
"""
result = subprocess.run(
["docker", "logs", "--tail", str(tail), container_id],
capture_output=True,
text=True,
)
if result.returncode == 0:
return result.stdout
return f"Failed to get logs: {result.stderr}"
def find_free_port(start: int = 10000, end: int = 20000) -> int:
"""Find a free TCP port in the given range.
Args:
start: Start of port range
end: End of port range
Returns:
Free port number
"""
import socket
for port in range(start, end):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
if s.connect_ex(("localhost", port)) != 0:
return port
raise RuntimeError(f"No free port found in range {start}-{end}")
-281
View File
@@ -1,281 +0,0 @@
"""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 re
import subprocess
from typing import Any
from src.services.docker.container import get_backend_network_name
logger = logging.getLogger(__name__)
TUNNEL_IMAGE = "cloudflare/cloudflared:latest"
def _tunnel_container_name(instance_name: str) -> str:
return f"tunnel-{instance_name.lower()}"
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,
)
if pull.returncode != 0:
logger.warning("Failed to pull %s: %s", TUNNEL_IMAGE, pull.stderr)
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,
)
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 _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:
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'.
"""
_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(
instance_name: str, container_port: int, target_url: str | None = None
) -> dict[str, str]:
"""Recreate a tunnel for an instance.
Args:
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}.
"""
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.
Returns:
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,
],
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 {
"tunnel_status": "error_response",
"status_code": status_code,
"healthy": False,
"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
View File
@@ -1,19 +0,0 @@
"""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",
]
-223
View File
@@ -1,223 +0,0 @@
"""Git commands scoped to a workspace directory."""
import asyncio
import logging
from dataclasses import dataclass
from src.models import Workspace
logger = logging.getLogger(__name__)
@dataclass
class GitStatus:
"""Parsed git status output."""
branch: str
modified: list[str]
added: list[str]
deleted: list[str]
untracked: list[str]
ahead: int = 0
behind: int = 0
@dataclass
class Commit:
"""A single git commit."""
hash: str
message: str
author: str
date: str
class GitOperations:
"""Run git commands within a workspace directory."""
def __init__(self, workspace: Workspace) -> None:
self.cwd = workspace.path
self.branch = workspace.branch
async def _run(self, *cmd: str) -> tuple[int, str, str]:
"""Run a git command and return (returncode, stdout, stderr)."""
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
return proc.returncode or 0, stdout.decode(), stderr.decode()
async def status(self) -> GitStatus:
"""Get git status for the workspace."""
returncode, stdout, _ = await self._run(
"git", "-C", self.cwd, "status", "--porcelain", "-b"
)
modified: list[str] = []
added: list[str] = []
deleted: list[str] = []
untracked: list[str] = []
branch = self.branch
ahead = 0
behind = 0
for line in stdout.splitlines():
if line.startswith("##"):
# Branch info line
branch_info = line[3:].strip()
if "..." in branch_info:
branch = branch_info.split("...")[0]
if "[ahead " in branch_info:
ahead_str = branch_info.split("[ahead ")[1].split("]")[0]
ahead = int(ahead_str.split(",")[0])
if "[behind " in branch_info:
behind_str = branch_info.split("[behind ")[1].split("]")[0]
behind = int(behind_str.split(",")[0])
else:
branch = branch_info
continue
if len(line) < 3:
continue
status_code = line[:2]
file_path = line[3:]
# XY format: X = index status, Y = working tree status
if status_code == "??":
untracked.append(file_path)
elif status_code[1] == "D" or status_code[0] == "D":
deleted.append(file_path)
elif status_code[0] == "A" or status_code[1] == "A":
added.append(file_path)
else:
modified.append(file_path)
return GitStatus(
branch=branch,
modified=modified,
added=added,
deleted=deleted,
untracked=untracked,
ahead=ahead,
behind=behind,
)
async def commit(self, message: str) -> None:
"""Stage all changes and commit."""
rc, _, err = await self._run("git", "-C", self.cwd, "add", "-A")
if rc != 0:
raise RuntimeError(f"Git add failed: {err}")
rc, _, err = await self._run("git", "-C", self.cwd, "commit", "-m", message)
if rc != 0:
raise RuntimeError(f"Git commit failed: {err}")
logger.info("Committed in workspace: %s", self.cwd)
async def push(self) -> None:
"""Push current branch to origin."""
rc, _, err = await self._run(
"git", "-C", self.cwd, "push", "origin", self.branch
)
if rc != 0:
raise RuntimeError(f"Git push failed: {err}")
logger.info("Pushed branch %s from workspace: %s", self.branch, self.cwd)
async def pull(self) -> None:
"""Pull current branch from origin."""
rc, _, err = await self._run(
"git", "-C", self.cwd, "pull", "origin", self.branch
)
if rc != 0:
raise RuntimeError(f"Git pull failed: {err}")
logger.info("Pulled branch %s in workspace: %s", self.branch, self.cwd)
async def fetch(self) -> None:
"""Fetch from origin."""
rc, _, err = await self._run("git", "-C", self.cwd, "fetch", "origin")
if rc != 0:
raise RuntimeError(f"Git fetch failed: {err}")
logger.info("Fetched origin for workspace: %s", self.cwd)
async def checkout(self, branch: str) -> None:
"""Checkout a branch."""
rc, _, err = await self._run("git", "-C", self.cwd, "checkout", branch)
if rc != 0:
raise RuntimeError(f"Git checkout failed: {err}")
self.branch = branch
logger.info("Checked out branch %s in workspace: %s", branch, self.cwd)
async def history(self, path: str | None = None, limit: int = 50) -> list[Commit]:
"""Get commit history.
Args:
path: Optional file path to filter history.
limit: Maximum number of commits.
Returns:
List of commits.
"""
cmd = [
"git",
"-C",
self.cwd,
"log",
f"--max-count={limit}",
"--pretty=format:%H|%s|%an|%ad",
"--date=iso",
]
if path:
cmd.extend(["--", path])
rc, stdout, err = await self._run(*cmd)
if rc != 0:
raise RuntimeError(f"Git log failed: {err}")
commits = []
for line in stdout.strip().splitlines():
parts = line.split("|", 3)
if len(parts) >= 4:
commits.append(
Commit(
hash=parts[0],
message=parts[1],
author=parts[2],
date=parts[3],
)
)
return commits
async def branches(self) -> tuple[list[str], str]:
"""List all branches and current branch.
Returns:
Tuple of (all_branches, current_branch).
"""
rc, stdout, err = await self._run(
"git", "-C", self.cwd, "branch", "-a", "--format=%(refname:short)"
)
if rc != 0:
raise RuntimeError(f"Git branch failed: {err}")
branches = []
current = self.branch
for line in stdout.strip().splitlines():
line = line.strip()
if line.startswith("HEAD") or line.endswith("/HEAD"):
continue
if line.startswith("remotes/origin/"):
branch_name = line.replace("remotes/origin/", "")
if branch_name not in branches:
branches.append(branch_name)
elif line and line not in branches:
branches.append(line)
return branches, current
-176
View File
@@ -1,176 +0,0 @@
"""Git operations for workspace management."""
import asyncio
import logging
import os
import subprocess
import tempfile
logger = logging.getLogger(__name__)
class GitService:
"""Low-level git operations for creating and syncing workspaces."""
@staticmethod
def _prepare_ssh_env(
ssh_key: str | None,
) -> tuple[dict[str, str] | None, str | None]:
"""Prepare environment for git commands with SSH authentication.
Returns a tuple of (env_dict, temp_key_path). Caller must clean up key_path.
"""
if not ssh_key:
return None, None
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
try:
os.write(fd, ssh_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"
}
return env, key_path
@staticmethod
async def clone(
remote_url: str, branch: str, path: str, ssh_key: str | None = None
) -> None:
"""Clone a repository to the given path.
Args:
remote_url: The git remote URL.
branch: The branch to clone.
path: The destination path for the clone.
ssh_key: Optional decrypted SSH private key for authentication.
Raises:
RuntimeError: If the clone fails.
"""
cmd = [
"git",
"clone",
"--branch",
branch,
"--single-branch",
remote_url,
path,
]
env, key_path = GitService._prepare_ssh_env(ssh_key)
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, **env} if env else None,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode().strip() if stderr else "unknown error"
logger.error("Git clone failed: %s", error_msg)
raise RuntimeError(f"Git clone failed: {error_msg}")
logger.debug("Cloned %s (branch: %s) to %s", remote_url, branch, path)
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
@staticmethod
async def fetch(path: str, ssh_key: str | None = None) -> None:
"""Fetch from origin.
Args:
path: The path to the local git repository.
ssh_key: Optional decrypted SSH private key for authentication.
Raises:
RuntimeError: If fetch fails.
"""
env, key_path = GitService._prepare_ssh_env(ssh_key)
try:
proc = await asyncio.create_subprocess_exec(
"git",
"-C",
path,
"fetch",
"origin",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, **env} if env else None,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode().strip() if stderr else "unknown error"
logger.error("Git fetch failed: %s", error_msg)
raise RuntimeError(f"Git fetch failed: {error_msg}")
logger.debug("Fetched origin for %s", path)
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
@staticmethod
async def pull(path: str, branch: str, ssh_key: str | None = None) -> None:
"""Pull latest changes from origin.
Args:
path: The path to the local git repository.
branch: The branch to pull.
ssh_key: Optional decrypted SSH private key for authentication.
Raises:
RuntimeError: If pull fails.
"""
env, key_path = GitService._prepare_ssh_env(ssh_key)
try:
proc = await asyncio.create_subprocess_exec(
"git",
"-C",
path,
"pull",
"origin",
branch,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, **env} if env else None,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode().strip() if stderr else "unknown error"
logger.error("Git pull failed: %s", error_msg)
raise RuntimeError(f"Git pull failed: {error_msg}")
logger.debug("Pulled origin/%s for %s", branch, path)
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
@staticmethod
def branch_exists_remotely(
path: str, branch: str, ssh_key: str | None = None
) -> bool:
"""Check if a branch exists on the remote.
Args:
path: The path to the local git repository.
branch: The branch name to check.
ssh_key: Optional decrypted SSH private key for authentication.
Returns:
True if the branch exists on origin, False otherwise.
"""
env, key_path = GitService._prepare_ssh_env(ssh_key)
try:
result = subprocess.run(
["git", "-C", path, "ls-remote", "--heads", "origin", branch],
capture_output=True,
text=True,
env={**os.environ, **env} if env else None,
)
exists = result.returncode == 0 and result.stdout.strip() != ""
logger.debug("Branch %s exists on remote: %s", branch, exists)
return exists
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
@@ -10,13 +10,12 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.database import SessionLocal from src.database import SessionLocal
from src.models import HealthCheck from src.models.health_check import HealthCheck
from src.models import ToolInstance from src.models.tool_instance import ToolInstance
from src.services.shared.correlation import get_correlation_id from src.services.correlation import get_correlation_id
from src.services.docker import get_container_status from src.services.docker import check_tunnel_health, get_container_status
from src.services.shared.tunnel import check_tunnel_health from src.services.event_bus import InstanceEventBus, InstanceEventPayload
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload from src.services.notification_service import notification_service
from src.services.shared.notification_service import notification_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1,7 +0,0 @@
"""Instance lifecycle services module."""
from src.services.instance.event_bus import InstanceEventBus
from src.services.instance.health_monitor import HealthMonitor
from src.services.instance.lifecycle_hooks import publish_lifecycle_event
__all__ = ["InstanceEventBus", "HealthMonitor", "publish_lifecycle_event"]
@@ -6,11 +6,11 @@ from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.models import InstanceEvent from src.models.instance_event import InstanceEvent
from src.models import ToolInstance from src.models.tool_instance import ToolInstance
from src.services.shared.correlation import get_correlation_id from src.services.correlation import get_correlation_id
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload from src.services.event_bus import InstanceEventBus, InstanceEventPayload
from src.services.shared.notification_service import notification_service from src.services.notification_service import notification_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,7 +26,7 @@ def resolve_base(manifest: dict) -> dict:
result = deepcopy(manifest) result = deepcopy(manifest)
base_definition_id = result.pop("base_definition_id", None) base_definition_id = result.pop("base_definition_id", None)
result.pop("base_version", None) base_version = result.pop("base_version", "latest")
if base_definition_id: if base_definition_id:
# This will be provided by the caller (they have the DB session) # This will be provided by the caller (they have the DB session)
@@ -118,11 +118,6 @@ def compile_dockerfile(manifest: dict) -> str:
# System packages (apt) # System packages (apt)
apt_packages = manifest.get("packages", {}).get("apt", []) apt_packages = manifest.get("packages", {}).get("apt", [])
if manifest.get("user"):
# Ensure sudo is available for permission-fixing startup scripts
apt_packages = list(apt_packages)
if "sudo" not in apt_packages:
apt_packages.append("sudo")
if apt_packages: if apt_packages:
lines.append("RUN apt-get update && apt-get install -y \\") lines.append("RUN apt-get update && apt-get install -y \\")
for pkg in apt_packages[:-1]: for pkg in apt_packages[:-1]:
@@ -172,17 +167,6 @@ def compile_dockerfile(manifest: dict) -> str:
lines.append(f"ENV HOME={home}") lines.append(f"ENV HOME={home}")
lines.append(f"ENV USER={name}") lines.append(f"ENV USER={name}")
lines.append("") lines.append("")
# Ensure home directory exists and is writable by the user
lines.append(
f"RUN mkdir -p {home} && chown {name}:{name} {home} && chmod 755 {home}"
)
lines.append("")
# Configure passwordless sudo so startup scripts can fix permissions
lines.append(
f'RUN echo "{name} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/{name} && chmod 0440 /etc/sudoers.d/{name}'
)
lines.append("")
# Build scripts # Build scripts
build_scripts = manifest.get("scripts", {}).get("build", []) build_scripts = manifest.get("scripts", {}).get("build", [])
@@ -197,11 +181,6 @@ def compile_dockerfile(manifest: dict) -> str:
if build_scripts: if build_scripts:
lines.append("") lines.append("")
# After build scripts, ensure everything in home is owned by the user
if user and build_scripts:
lines.append(f"RUN chown -R {name}:{name} {home}")
lines.append("")
# Create mount target directories # Create mount target directories
mounts = manifest.get("mounts", []) mounts = manifest.get("mounts", [])
if mounts: if mounts:
@@ -329,21 +308,7 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
service["volumes"] = sort_volumes_by_specificity(volumes) service["volumes"] = sort_volumes_by_specificity(volumes)
compose = {"services": {"app": service}} compose = {"services": {"app": service}}
result = yaml.dump(compose, default_flow_style=False) return yaml.dump(compose, default_flow_style=False)
# Debug: log mount resolution so we can diagnose missing mounts
import logging
logger = logging.getLogger(__name__)
logger.debug(
"compile_compose: REPO_PATH=%s SSH_PATH=%s EXTRA_VOLUMES=%s mounts=%s volumes=%s",
variables.get("REPO_PATH", "<empty>"),
variables.get("SSH_PATH", "<empty>"),
variables.get("EXTRA_VOLUMES", []),
manifest.get("mounts", []),
volumes,
)
return result
def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str: def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
@@ -8,7 +8,7 @@ from sqlalchemy import func, select, update
from sqlalchemy.engine import CursorResult from sqlalchemy.engine import CursorResult
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.models import Notification from src.models.notification import Notification
class NotificationService: class NotificationService:
-51
View File
@@ -1,51 +0,0 @@
"""Shared services module."""
from src.services.shared.correlation import CorrelationIdMiddleware, get_correlation_id
from src.services.shared.file_service import FileEntry, FileService
from src.services.shared.notification_service import NotificationService
from src.services.shared.permission_fixer import (
PermissionFixError,
apply_mount_permissions,
apply_ssh_permissions,
check_root_user_available,
)
from src.services.shared.readiness_probe import execute_probe
from src.services.shared.ssh_keys import (
cleanup_ssh_key_files,
prepare_ssh_key_files,
write_ssh_config,
)
from src.services.shared.tunnel import (
check_tunnel_health,
recreate_tunnel,
start_tunnel,
stop_tunnel,
)
from src.services.shared.workspace_manager import (
SyncResult,
WorkspaceHasInstancesError,
WorkspaceManager,
)
__all__ = [
"CorrelationIdMiddleware",
"FileEntry",
"FileService",
"NotificationService",
"PermissionFixError",
"SyncResult",
"WorkspaceHasInstancesError",
"WorkspaceManager",
"apply_mount_permissions",
"apply_ssh_permissions",
"check_root_user_available",
"check_tunnel_health",
"cleanup_ssh_key_files",
"execute_probe",
"get_correlation_id",
"prepare_ssh_key_files",
"recreate_tunnel",
"start_tunnel",
"stop_tunnel",
"write_ssh_config",
]

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