Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8816ee02ce | |||
| 6104f592eb | |||
| 0591b00ded | |||
| 0127d283a6 | |||
| 2757ef3b4f | |||
| ab55da280c | |||
| 4e076c36d2 | |||
| c6d62f84da | |||
| 8a0d82f49b | |||
| 0c74997cfe |
@@ -0,0 +1,42 @@
|
|||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Alembic cache
|
||||||
|
alembic/versions/__pycache__/
|
||||||
|
|
||||||
|
# Pi lens cache
|
||||||
|
.pi-lens/
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Scripts not needed in container
|
||||||
|
scripts/
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""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")
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""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")
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Config module."""
|
||||||
+101
-267
@@ -7,16 +7,24 @@ 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.config_profile import ConfigProfile, ConfigProfileInclude
|
from src.models import ConfigProfile, ConfigProfileInclude
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.tool_type import ToolType
|
from src.models import ToolType
|
||||||
|
from src.models import UserConfig
|
||||||
|
from src.schemas.config import (
|
||||||
|
ConfigProfileCreate,
|
||||||
|
ConfigProfileIncludeUpdate,
|
||||||
|
ConfigProfileResponse,
|
||||||
|
ConfigProfileUpdate,
|
||||||
|
DefaultProfilesUpdate,
|
||||||
|
ValidateGitUrlRequest,
|
||||||
|
ValidateGitUrlResponse,
|
||||||
|
)
|
||||||
from src.services.config_profile_resolver import (
|
from src.services.config_profile_resolver import (
|
||||||
ConfigProfileCycleError,
|
ConfigProfileCycleError,
|
||||||
check_include_cycle,
|
check_include_cycle,
|
||||||
@@ -33,16 +41,6 @@ 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
|
||||||
@@ -60,247 +58,6 @@ 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:
|
||||||
@@ -840,20 +597,97 @@ 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):
|
# ---------------------------------------------------------------------------
|
||||||
url: str = Field(description="Git remote URL to validate")
|
# Default profile management
|
||||||
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}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ValidateGitUrlResponse(BaseModel):
|
@router.get("/defaults")
|
||||||
valid: bool
|
async def get_default_profiles_endpoint(
|
||||||
suggested_url: str | None = None
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
branches: list[str] | None = None
|
session: AsyncSession = Depends(get_db_session),
|
||||||
default_branch: str | None = None
|
) -> dict:
|
||||||
error: str | None = None
|
"""Get all default profile mappings for the current user."""
|
||||||
error_code: str | None = None
|
result = await session.execute(
|
||||||
|
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||||
|
)
|
||||||
|
user_config = result.scalar_one_or_none()
|
||||||
|
return {"default_profiles": user_config.default_profiles if user_config else {}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/defaults")
|
||||||
|
async def set_default_profiles_endpoint(
|
||||||
|
data: DefaultProfilesUpdate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Set default profile mappings for the current user."""
|
||||||
|
await _validate_default_profiles(session, user_id, data.default_profiles)
|
||||||
|
user_config = await _get_or_create_user_config(session, user_id)
|
||||||
|
user_config.config = {
|
||||||
|
**user_config.config,
|
||||||
|
"default_profiles": data.default_profiles,
|
||||||
|
}
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user_config)
|
||||||
|
return {"default_profiles": user_config.default_profiles}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/defaults/{tool_type_id}")
|
||||||
|
async def get_default_profile_for_tool_type_endpoint(
|
||||||
|
tool_type_id: str,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get the default profile ID for a specific tool type."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||||
|
)
|
||||||
|
user_config = result.scalar_one_or_none()
|
||||||
|
profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None
|
||||||
|
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
|
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
|
||||||
@@ -891,7 +725,7 @@ 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.ssh_key import SSHKey
|
from src.models import SSHKey
|
||||||
from src.services.ssh_keys import _get_fernet
|
from src.services.ssh_keys import _get_fernet
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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.git_repository import GitRepository
|
from src.models import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models import SSHKey
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ 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, ConfigDict
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -17,8 +16,15 @@ from src.auth.dependencies import (
|
|||||||
get_db_session,
|
get_db_session,
|
||||||
)
|
)
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models.git_repository import GitRepository
|
from src.models import GitRepository
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models 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,
|
||||||
@@ -218,43 +224,6 @@ 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],
|
||||||
@@ -599,10 +568,6 @@ 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,
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ 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()
|
||||||
|
|
||||||
@@ -16,45 +22,6 @@ 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.tool_instance import ToolInstance
|
from src.models import ToolInstance
|
||||||
from src.models.tool_type import ToolType
|
from src.models import ToolType
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ 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.user_config import UserConfig
|
from src.models import UserConfig
|
||||||
from src.services.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"])
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Project module."""
|
||||||
@@ -3,7 +3,6 @@ import shutil
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -13,38 +12,20 @@ from src.auth.dependencies import (
|
|||||||
get_current_user_id,
|
get_current_user_id,
|
||||||
get_db_session,
|
get_db_session,
|
||||||
)
|
)
|
||||||
from src.models.git_repository import GitRepository
|
from src.models import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models import SSHKey
|
||||||
from src.models.tool_instance import ToolInstance
|
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,
|
||||||
@@ -101,7 +82,7 @@ async def list_projects(
|
|||||||
)
|
)
|
||||||
projects = result.scalars().all()
|
projects = result.scalars().all()
|
||||||
|
|
||||||
from src.models.workspace import Workspace
|
from src.models import Workspace
|
||||||
|
|
||||||
enriched = []
|
enriched = []
|
||||||
for project in projects:
|
for project in projects:
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
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.ssh_key import SSHKey
|
from src.models 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"])
|
||||||
|
|
||||||
@@ -53,36 +59,6 @@ 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,
|
||||||
@@ -171,7 +147,9 @@ 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(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
|
||||||
|
)
|
||||||
|
|
||||||
await session.delete(ssh_key)
|
await session.delete(ssh_key)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -203,7 +181,9 @@ 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(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
|
||||||
|
)
|
||||||
|
|
||||||
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()
|
||||||
@@ -242,7 +222,9 @@ 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(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
|
||||||
|
)
|
||||||
|
|
||||||
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
|
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""System module."""
|
||||||
@@ -12,9 +12,9 @@ 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.terminal_session import TerminalSessionModel
|
from src.models import TerminalSessionModel
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models import ToolInstance
|
||||||
from src.models.tool_type import ToolType
|
from src.models import ToolType
|
||||||
from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager
|
from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tool module."""
|
||||||
@@ -9,8 +9,8 @@ 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.tool_definition_manifest import ToolDefinitionManifest
|
from src.models import ToolDefinitionManifest
|
||||||
from src.models.tool_type import ToolType
|
from src.models import ToolType
|
||||||
from src.services.manifest_compiler import (
|
from src.services.manifest_compiler import (
|
||||||
compile_compose,
|
compile_compose,
|
||||||
compile_dockerfile,
|
compile_dockerfile,
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from fastapi import (
|
|||||||
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
|
||||||
|
|
||||||
@@ -30,12 +29,12 @@ from src.auth.dependencies import (
|
|||||||
)
|
)
|
||||||
from src.services.event_bus import InstanceEventBus
|
from src.services.event_bus import InstanceEventBus
|
||||||
from src.services.lifecycle_hooks import publish_lifecycle_event
|
from src.services.lifecycle_hooks import publish_lifecycle_event
|
||||||
from src.models.config_profile import ConfigProfile
|
from src.models import ConfigProfile
|
||||||
from src.models.git_repository import GitRepository
|
from src.models import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models import SSHKey
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models import ToolInstance
|
||||||
from src.models.tool_type import ToolType
|
from src.models import ToolType
|
||||||
from src.services.clone import check_dirty_state, clone_repository
|
from src.services.clone import check_dirty_state, clone_repository
|
||||||
from src.services.config_profile_resolver import (
|
from src.services.config_profile_resolver import (
|
||||||
ConfigProfileCycleError,
|
ConfigProfileCycleError,
|
||||||
@@ -82,6 +81,7 @@ from src.services.manifest_compiler import (
|
|||||||
from src.services.permission_fixer import apply_mount_permissions, apply_ssh_permissions
|
from src.services.permission_fixer import apply_mount_permissions, apply_ssh_permissions
|
||||||
from src.services.readiness_probe import execute_probe
|
from src.services.readiness_probe import execute_probe
|
||||||
from src.services.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files
|
from src.services.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()
|
||||||
@@ -418,48 +418,6 @@ 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"
|
|
||||||
)
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_config_profile(
|
async def _validate_config_profile(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
profile_id: str | None,
|
profile_id: str | None,
|
||||||
@@ -856,7 +814,7 @@ async def create_instance(
|
|||||||
workspace = None
|
workspace = None
|
||||||
workspace_id = None
|
workspace_id = None
|
||||||
if data.workspace_id:
|
if data.workspace_id:
|
||||||
from src.models.workspace import Workspace as WorkspaceModel
|
from src.models import Workspace as WorkspaceModel
|
||||||
|
|
||||||
try:
|
try:
|
||||||
workspace_id = uuid.UUID(data.workspace_id)
|
workspace_id = uuid.UUID(data.workspace_id)
|
||||||
@@ -893,9 +851,25 @@ 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 = (
|
|
||||||
data.display_name or f"{tool_type.display_name} - {repo.name}"
|
# Auto-generate display name with numbering when duplicates exist
|
||||||
|
if data.display_name:
|
||||||
|
instance_display = data.display_name
|
||||||
|
else:
|
||||||
|
auto_name = f"{_project.name} / {repo.name} / {tool_type.display_name}"
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolInstance).where(
|
||||||
|
ToolInstance.project_id == project_id,
|
||||||
|
ToolInstance.repository_id == repo_id,
|
||||||
|
ToolInstance.tool_type_id == tool_type_id,
|
||||||
|
ToolInstance.owner_id == user_id,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
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)
|
||||||
@@ -1040,7 +1014,7 @@ services:
|
|||||||
|
|
||||||
elif tool_type.definition_type == "manifest":
|
elif tool_type.definition_type == "manifest":
|
||||||
# Manifest-based: generate compose only; image built lazily on start
|
# Manifest-based: generate compose only; image built lazily on start
|
||||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
from src.models import ToolDefinitionManifest
|
||||||
|
|
||||||
manifest_def = await session.get(
|
manifest_def = await session.get(
|
||||||
ToolDefinitionManifest, tool_type.manifest_id
|
ToolDefinitionManifest, tool_type.manifest_id
|
||||||
@@ -1325,7 +1299,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.tool_definition_manifest import ToolDefinitionManifest
|
from src.models 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)
|
||||||
@@ -1521,7 +1495,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.tool_definition_manifest import ToolDefinitionManifest
|
from src.models 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:
|
||||||
@@ -1708,7 +1682,7 @@ async def start_instance(
|
|||||||
# Determine repo path (workspace takes precedence)
|
# Determine repo path (workspace takes precedence)
|
||||||
repo_path = ""
|
repo_path = ""
|
||||||
if instance.workspace_id:
|
if instance.workspace_id:
|
||||||
from src.models.workspace import Workspace as WorkspaceModel
|
from src.models import Workspace as WorkspaceModel
|
||||||
|
|
||||||
workspace = await session.get(WorkspaceModel, instance.workspace_id)
|
workspace = await session.get(WorkspaceModel, instance.workspace_id)
|
||||||
if workspace:
|
if workspace:
|
||||||
@@ -2751,7 +2725,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.instance_event import InstanceEvent
|
from src.models 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)
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -12,8 +10,14 @@ from src.api.tool_types_validation import (
|
|||||||
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.tool_type import ToolType
|
from src.models 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"])
|
||||||
|
|
||||||
@@ -29,237 +33,6 @@ 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,
|
||||||
@@ -461,12 +234,6 @@ 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",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""User module."""
|
||||||
@@ -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.user_config import UserConfig
|
from src.models import UserConfig
|
||||||
|
from src.schemas.user import UserConfigResponse, UserConfigUpdate
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -38,28 +38,6 @@ 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,20 +16,6 @@ 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,
|
||||||
@@ -77,12 +63,16 @@ 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(status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty")
|
raise HTTPException(
|
||||||
|
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(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email")
|
raise HTTPException(
|
||||||
|
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()
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Workspace module."""
|
||||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
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.workspace import Workspace
|
from src.models import Workspace
|
||||||
from src.services.file_service import FileService
|
from src.services.file_service import FileService
|
||||||
|
|
||||||
router = APIRouter(prefix="/workspaces/{workspace_id}/files")
|
router = APIRouter(prefix="/workspaces/{workspace_id}/files")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
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.workspace import Workspace
|
from src.models import Workspace
|
||||||
from src.services.git_operations import GitOperations
|
from src.services.git_operations import GitOperations
|
||||||
|
|
||||||
router = APIRouter(prefix="/workspaces/{workspace_id}/git")
|
router = APIRouter(prefix="/workspaces/{workspace_id}/git")
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ 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.tool_instance import ToolInstance
|
from src.models import ToolInstance
|
||||||
from src.models.workspace import Workspace
|
from src.models import Workspace
|
||||||
|
|
||||||
router = APIRouter(prefix="/workspaces/{workspace_id}/instances")
|
router = APIRouter(prefix="/workspaces/{workspace_id}/instances")
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
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.git_repository import GitRepository
|
from src.models import GitRepository
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models import ToolInstance
|
||||||
from src.models.workspace import Workspace
|
from src.models import Workspace
|
||||||
from src.services.workspace_manager import WorkspaceHasInstancesError, WorkspaceManager
|
from src.services.workspace_manager import WorkspaceHasInstancesError, WorkspaceManager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -29,14 +29,15 @@ from src.api.workspace_git import router as workspace_git_router
|
|||||||
from src.api.workspace_instances import router as workspace_instances_router
|
from src.api.workspace_instances import router as workspace_instances_router
|
||||||
from src.api.workspaces import all_workspaces_router, router as workspaces_router
|
from src.api.workspaces import all_workspaces_router, router as workspaces_router
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models.notification import Notification # noqa: F401 – Alembic model discovery
|
from src.models import Notification # noqa: F401 – Alembic model discovery
|
||||||
from src.models.terminal_session import TerminalSessionModel # noqa: F401 – Alembic model discovery
|
from src.models 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.correlation import CorrelationIdMiddleware
|
||||||
from src.services.event_bus import InstanceEventBus
|
from src.services.event_bus import InstanceEventBus
|
||||||
from src.services.health_monitor import HealthMonitor
|
from src.services.health_monitor import HealthMonitor
|
||||||
@@ -135,6 +136,10 @@ 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.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
from src.models.config.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.project.git_repository import GitRepository
|
||||||
from src.models.health_check import HealthCheck
|
from src.models.project.project import Project
|
||||||
from src.models.instance_event import InstanceEvent
|
from src.models.project.workspace import Workspace
|
||||||
from src.models.notification import Notification
|
from src.models.system.health_check import HealthCheck
|
||||||
from src.models.project import Project
|
from src.models.system.instance_event import InstanceEvent
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.system.notification import Notification
|
||||||
from src.models.terminal_session import TerminalSessionModel
|
from src.models.system.terminal_session import TerminalSessionModel
|
||||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models.tool.tool_instance import ToolInstance
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user.ssh_key import SSHKey
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user.user import User
|
||||||
from src.models.workspace import Workspace
|
from src.models.user.user_config import UserConfig
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Config models module."""
|
||||||
|
|
||||||
|
from src.models.config.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
|
|
||||||
|
__all__ = ["ConfigProfile", "ConfigProfileInclude"]
|
||||||
+13
-2
@@ -1,7 +1,15 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean
|
from sqlalchemy import (
|
||||||
|
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
|
||||||
|
|
||||||
@@ -9,12 +17,15 @@ 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.tool_type import ToolType
|
from src.models 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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Project models module."""
|
||||||
|
|
||||||
|
from src.models.project.git_repository import GitRepository
|
||||||
|
from src.models.project.project import Project
|
||||||
|
from src.models.project.workspace import Workspace
|
||||||
|
|
||||||
|
__all__ = ["GitRepository", "Project", "Workspace"]
|
||||||
+1
-1
@@ -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.ssh_key import SSHKey
|
from src.models 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.git_repository import GitRepository
|
from src.models import GitRepository
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models import SSHKey
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|||||||
from src.models.base import Base, TimestampMixin
|
from src.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.git_repository import GitRepository
|
from src.models import GitRepository
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""System models module."""
|
||||||
|
|
||||||
|
from src.models.system.health_check import HealthCheck
|
||||||
|
from src.models.system.instance_event import InstanceEvent
|
||||||
|
from src.models.system.notification import Notification
|
||||||
|
from src.models.system.terminal_session import TerminalSessionModel
|
||||||
|
|
||||||
|
__all__ = ["HealthCheck", "InstanceEvent", "Notification", "TerminalSessionModel"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Tool models module."""
|
||||||
|
|
||||||
|
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest
|
||||||
|
from src.models.tool.tool_instance import ToolInstance
|
||||||
|
from src.models.tool.tool_type import ToolType
|
||||||
|
|
||||||
|
__all__ = ["ToolDefinitionManifest", "ToolInstance", "ToolType"]
|
||||||
@@ -9,12 +9,12 @@ 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.config_profile import ConfigProfile
|
from src.models import ConfigProfile
|
||||||
from src.models.git_repository import GitRepository
|
from src.models import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.tool_type import ToolType
|
from src.models import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.models.workspace import Workspace
|
from src.models import Workspace
|
||||||
|
|
||||||
|
|
||||||
class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
@@ -6,8 +6,7 @@ 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
|
||||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""User models module."""
|
||||||
|
|
||||||
|
from src.models.user.ssh_key import SSHKey
|
||||||
|
from src.models.user.user import User
|
||||||
|
from src.models.user.user_config import UserConfig
|
||||||
|
|
||||||
|
__all__ = ["SSHKey", "User", "UserConfig"]
|
||||||
@@ -7,8 +7,8 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models import SSHKey
|
||||||
from src.models.user_config import UserConfig
|
from src.models import UserConfig
|
||||||
|
|
||||||
|
|
||||||
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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")
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Config schemas module."""
|
||||||
|
|
||||||
|
from src.schemas.config.config_profile import (
|
||||||
|
ConfigProfileCreate,
|
||||||
|
ConfigProfileIncludeUpdate,
|
||||||
|
ConfigProfileResponse,
|
||||||
|
ConfigProfileUpdate,
|
||||||
|
DefaultProfilesUpdate,
|
||||||
|
GitMountItem,
|
||||||
|
GitMountMapping,
|
||||||
|
MountItem,
|
||||||
|
ValidateGitUrlRequest,
|
||||||
|
ValidateGitUrlResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ConfigProfileCreate",
|
||||||
|
"ConfigProfileIncludeUpdate",
|
||||||
|
"ConfigProfileResponse",
|
||||||
|
"ConfigProfileUpdate",
|
||||||
|
"DefaultProfilesUpdate",
|
||||||
|
"GitMountItem",
|
||||||
|
"GitMountMapping",
|
||||||
|
"MountItem",
|
||||||
|
"ValidateGitUrlRequest",
|
||||||
|
"ValidateGitUrlResponse",
|
||||||
|
]
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""Config profile request/response schemas."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
from src.api.shared_validators import validate_env_vars as _validate_env_vars
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_uuid(v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
try:
|
||||||
|
uuid.UUID(v)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"Invalid UUID: {v}") from exc
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class GitMountMapping(BaseModel):
|
||||||
|
source_path: str = Field(
|
||||||
|
description="Path within repository (supports glob patterns)"
|
||||||
|
)
|
||||||
|
target_path: str = Field(description="Absolute path inside container")
|
||||||
|
|
||||||
|
@field_validator("source_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_source_path(cls, v: str) -> str:
|
||||||
|
if v.startswith("/"):
|
||||||
|
raise ValueError("source_path must be relative (no leading /)")
|
||||||
|
if ".." in v:
|
||||||
|
raise ValueError("source_path cannot contain path traversal (..)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("target_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_target_path(cls, v: str) -> str:
|
||||||
|
if ".." in v:
|
||||||
|
raise ValueError("target_path cannot contain path traversal (..)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class GitMountItem(BaseModel):
|
||||||
|
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
|
||||||
|
source_path: str | None = Field(
|
||||||
|
default=None, description="Path within repository (legacy single mapping)"
|
||||||
|
)
|
||||||
|
target_path: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Absolute path inside container (legacy single mapping)",
|
||||||
|
)
|
||||||
|
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
||||||
|
mappings: list[GitMountMapping] | None = Field(
|
||||||
|
default=None, description="Multiple source/target mappings from the same repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("remote_url")
|
||||||
|
@classmethod
|
||||||
|
def validate_remote_url(cls, v: str) -> str:
|
||||||
|
if not v.startswith(("http://", "https://", "git@", "ssh://")):
|
||||||
|
raise ValueError(
|
||||||
|
"remote_url must be a valid git URL (https://, git@, or ssh://)"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("source_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_source_path(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if v.startswith("/"):
|
||||||
|
raise ValueError("source_path must be relative (no leading /)")
|
||||||
|
if ".." in v:
|
||||||
|
raise ValueError("source_path cannot contain path traversal (..)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("target_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_target_path(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if ".." in v:
|
||||||
|
raise ValueError("target_path cannot contain path traversal (..)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def check_mappings_or_legacy(self):
|
||||||
|
has_legacy = self.source_path is not None and self.target_path is not None
|
||||||
|
has_mappings = self.mappings is not None and len(self.mappings) > 0
|
||||||
|
if not has_legacy and not has_mappings:
|
||||||
|
raise ValueError(
|
||||||
|
"Git mount must have either 'mappings' (non-empty array) or both 'source_path' and 'target_path'"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class MountItem(BaseModel):
|
||||||
|
target: str = Field(description="Absolute mount target path")
|
||||||
|
mode: str = Field(default="rw", description="Mount mode: ro or rw")
|
||||||
|
files: dict = Field(
|
||||||
|
default_factory=dict, description="Files as {relative_path: content}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("target")
|
||||||
|
@classmethod
|
||||||
|
def validate_target(cls, v: str) -> str:
|
||||||
|
if not v.startswith("/"):
|
||||||
|
raise ValueError("Mount target must be absolute (start with /)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("mode")
|
||||||
|
@classmethod
|
||||||
|
def validate_mode(cls, v: str) -> str:
|
||||||
|
if v not in ("ro", "rw"):
|
||||||
|
raise ValueError("Mount mode must be 'ro' or 'rw'")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("files")
|
||||||
|
@classmethod
|
||||||
|
def validate_files(cls, v: dict) -> dict:
|
||||||
|
for path in v:
|
||||||
|
if ".." in path or not path:
|
||||||
|
raise ValueError(f"Invalid file path: {path}")
|
||||||
|
if path.startswith("/"):
|
||||||
|
raise ValueError(
|
||||||
|
f"Mount file paths must be relative (got: {path}). "
|
||||||
|
f"The mount target defines the absolute container path."
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileCreate(BaseModel):
|
||||||
|
name: str = Field(description="Profile name (unique per user)")
|
||||||
|
description: str | None = Field(default=None, description="Optional description")
|
||||||
|
project_id: str | None = Field(default=None, description="Optional project ID")
|
||||||
|
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
||||||
|
env_vars: dict = Field(default_factory=dict, description="Environment variables")
|
||||||
|
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
|
||||||
|
mounts: list[MountItem] = Field(
|
||||||
|
default_factory=list, description="Mount definitions"
|
||||||
|
)
|
||||||
|
files: dict = Field(
|
||||||
|
default_factory=dict, description="Files as {relative_path: content}"
|
||||||
|
)
|
||||||
|
git_mounts: list[GitMountItem] = Field(
|
||||||
|
default_factory=list, description="Git repository mounts"
|
||||||
|
)
|
||||||
|
is_default: bool = Field(
|
||||||
|
default=False, description="Whether this is the default profile for its scope"
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("project_id", "tool_type_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_uuids(cls, v: str | None) -> str | None:
|
||||||
|
return _validate_uuid(v)
|
||||||
|
|
||||||
|
@field_validator("files")
|
||||||
|
@classmethod
|
||||||
|
def validate_files(cls, v: dict) -> dict:
|
||||||
|
for path in v:
|
||||||
|
if ".." in path or not path:
|
||||||
|
raise ValueError(f"Invalid file path: {path}")
|
||||||
|
if path.startswith("/"):
|
||||||
|
raise ValueError(
|
||||||
|
f"File paths must be relative (got: {path}). "
|
||||||
|
f"Use Mounts for absolute container paths."
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("env_vars")
|
||||||
|
@classmethod
|
||||||
|
def validate_env_vars(cls, v: dict) -> dict:
|
||||||
|
result = _validate_env_vars(v)
|
||||||
|
if result is None:
|
||||||
|
raise ValueError("env_vars must be a JSON object")
|
||||||
|
return result
|
||||||
|
|
||||||
|
@field_validator("runtime_hints")
|
||||||
|
@classmethod
|
||||||
|
def validate_runtime_hints(cls, v: dict) -> dict:
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
raise ValueError("runtime_hints must be a JSON object")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("mounts")
|
||||||
|
@classmethod
|
||||||
|
def validate_mounts(cls, v: list) -> list:
|
||||||
|
if not isinstance(v, list):
|
||||||
|
raise ValueError("mounts must be a JSON array")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, description="Profile name")
|
||||||
|
description: str | None = Field(default=None, description="Optional description")
|
||||||
|
project_id: str | None = Field(default=None, description="Optional project ID")
|
||||||
|
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
||||||
|
env_vars: dict | None = Field(default=None, description="Environment variables")
|
||||||
|
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
|
||||||
|
mounts: list[MountItem] | None = Field(
|
||||||
|
default=None, description="Mount definitions"
|
||||||
|
)
|
||||||
|
files: dict | None = Field(
|
||||||
|
default=None, description="Files as {relative_path: content}"
|
||||||
|
)
|
||||||
|
git_mounts: list[GitMountItem] | None = Field(
|
||||||
|
default=None, description="Git repository mounts"
|
||||||
|
)
|
||||||
|
is_default: bool | None = Field(
|
||||||
|
default=None, description="Whether this is the default profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("project_id", "tool_type_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_uuids(cls, v: str | None) -> str | None:
|
||||||
|
return _validate_uuid(v)
|
||||||
|
|
||||||
|
@field_validator("files")
|
||||||
|
@classmethod
|
||||||
|
def validate_files(cls, v: dict | None) -> dict | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
for path in v:
|
||||||
|
if ".." in path or path.startswith("/") or not path:
|
||||||
|
raise ValueError(f"Invalid file path: {path}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileIncludeUpdate(BaseModel):
|
||||||
|
includes: list[str] = Field(description="Ordered list of included profile IDs")
|
||||||
|
|
||||||
|
@field_validator("includes")
|
||||||
|
@classmethod
|
||||||
|
def validate_includes(cls, v: list) -> list:
|
||||||
|
for item in v:
|
||||||
|
try:
|
||||||
|
uuid.UUID(item)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"Invalid UUID in includes: {item}") from exc
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
project_id: str | None
|
||||||
|
tool_type_id: str | None
|
||||||
|
env_vars: dict
|
||||||
|
runtime_hints: dict
|
||||||
|
mounts: list
|
||||||
|
files: dict
|
||||||
|
git_mounts: list
|
||||||
|
is_default: bool
|
||||||
|
includes: list[dict]
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultProfilesUpdate(BaseModel):
|
||||||
|
default_profiles: dict[str, str] = Field(
|
||||||
|
description="Mapping of tool_type_id -> profile_id for default profiles"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateGitUrlRequest(BaseModel):
|
||||||
|
url: str = Field(description="Git remote URL to validate")
|
||||||
|
ssh_key_id: str | None = Field(
|
||||||
|
default=None, description="Optional SSH key ID for private repos"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateGitUrlResponse(BaseModel):
|
||||||
|
valid: bool
|
||||||
|
suggested_url: str | None = None
|
||||||
|
branches: list[str] | None = None
|
||||||
|
default_branch: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
error_code: str | None = None
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Project schemas module."""
|
||||||
|
|
||||||
|
from src.schemas.project.git_repository import (
|
||||||
|
GitRepositoryCreate,
|
||||||
|
GitRepositoryResponse,
|
||||||
|
UpdateSSHKeyRequest,
|
||||||
|
URLParseRequest,
|
||||||
|
URLParseResponse,
|
||||||
|
)
|
||||||
|
from src.schemas.project.project import (
|
||||||
|
ProjectCreate,
|
||||||
|
ProjectResponse,
|
||||||
|
ProjectUpdate,
|
||||||
|
SetDefaultSSHKeyRequest,
|
||||||
|
)
|
||||||
|
from src.schemas.project.ssh_key import (
|
||||||
|
SSHKeyCreate,
|
||||||
|
SSHKeyResponse,
|
||||||
|
SignPayloadRequest,
|
||||||
|
SignatureResponse,
|
||||||
|
VerifySignatureRequest,
|
||||||
|
VerifySignatureResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GitRepositoryCreate",
|
||||||
|
"GitRepositoryResponse",
|
||||||
|
"ProjectCreate",
|
||||||
|
"ProjectResponse",
|
||||||
|
"ProjectUpdate",
|
||||||
|
"SSHKeyCreate",
|
||||||
|
"SSHKeyResponse",
|
||||||
|
"SetDefaultSSHKeyRequest",
|
||||||
|
"SignPayloadRequest",
|
||||||
|
"SignatureResponse",
|
||||||
|
"URLParseRequest",
|
||||||
|
"URLParseResponse",
|
||||||
|
"UpdateSSHKeyRequest",
|
||||||
|
"VerifySignatureRequest",
|
||||||
|
"VerifySignatureResponse",
|
||||||
|
]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Git repository request/response schemas."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class GitRepositoryCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
remote_url: str | None = None
|
||||||
|
force_original_url: bool = False
|
||||||
|
ssh_key_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class URLParseRequest(BaseModel):
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class URLParseResponse(BaseModel):
|
||||||
|
original_url: str
|
||||||
|
base_url: str | None
|
||||||
|
is_valid_clone_url: bool
|
||||||
|
needs_parsing: bool
|
||||||
|
host: str | None
|
||||||
|
message: str
|
||||||
|
error_code: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class GitRepositoryResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
project_id: uuid.UUID | None
|
||||||
|
owner_id: uuid.UUID
|
||||||
|
is_mirror: bool
|
||||||
|
remote_url: str | None
|
||||||
|
last_push: datetime | None
|
||||||
|
ssh_key_id: uuid.UUID | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateSSHKeyRequest(BaseModel):
|
||||||
|
ssh_key_id: str | None = None
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""SSH key request/response schemas."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class SSHKeyCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class SSHKeyResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
public_key: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SignPayloadRequest(BaseModel):
|
||||||
|
payload: str
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureResponse(BaseModel):
|
||||||
|
signature: str
|
||||||
|
|
||||||
|
|
||||||
|
class VerifySignatureRequest(BaseModel):
|
||||||
|
payload: str
|
||||||
|
signature: str
|
||||||
|
|
||||||
|
|
||||||
|
class VerifySignatureResponse(BaseModel):
|
||||||
|
valid: bool
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""System schemas module."""
|
||||||
|
|
||||||
|
from src.schemas.system.health import (
|
||||||
|
DatabaseHealth,
|
||||||
|
DatabaseHealthResponse,
|
||||||
|
DiskHealth,
|
||||||
|
HealthChecks,
|
||||||
|
HealthResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DatabaseHealth",
|
||||||
|
"DatabaseHealthResponse",
|
||||||
|
"DiskHealth",
|
||||||
|
"HealthChecks",
|
||||||
|
"HealthResponse",
|
||||||
|
]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""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]
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Tool schemas module."""
|
||||||
|
|
||||||
|
from src.schemas.tool.tool_instance import CreateInstanceRequest, StartInstanceRequest
|
||||||
|
from src.schemas.tool.tool_type import (
|
||||||
|
ToolTypeCreate,
|
||||||
|
ToolTypeResponse,
|
||||||
|
ToolTypeUpdate,
|
||||||
|
ToolTypeValidateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CreateInstanceRequest",
|
||||||
|
"StartInstanceRequest",
|
||||||
|
"ToolTypeCreate",
|
||||||
|
"ToolTypeResponse",
|
||||||
|
"ToolTypeUpdate",
|
||||||
|
"ToolTypeValidateRequest",
|
||||||
|
]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Tool instance request/response schemas."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class CreateInstanceRequest(BaseModel):
|
||||||
|
"""Request body for creating a tool instance."""
|
||||||
|
|
||||||
|
model_config = {"extra": "ignore"}
|
||||||
|
|
||||||
|
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
|
||||||
|
display_name: str | None = Field(
|
||||||
|
default=None, description="Optional display name for the instance"
|
||||||
|
)
|
||||||
|
workspace_id: str | None = Field(
|
||||||
|
default=None, description="UUID of workspace to mount (replaces clone_mode)"
|
||||||
|
)
|
||||||
|
clone_mode: str = Field(
|
||||||
|
default="mount", description="Repository access mode: 'mount' or 'clone'"
|
||||||
|
)
|
||||||
|
branch: str | None = Field(
|
||||||
|
default="main", description="Branch to clone (when clone_mode='clone')"
|
||||||
|
)
|
||||||
|
new_branch: str | None = Field(
|
||||||
|
default=None, description="Create a new local branch after cloning"
|
||||||
|
)
|
||||||
|
config_profile_id: str | None = Field(
|
||||||
|
default=None, description="Optional config profile ID for launch"
|
||||||
|
)
|
||||||
|
ssh_key_ids: list[str] = Field(
|
||||||
|
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StartInstanceRequest(BaseModel):
|
||||||
|
"""Request body for starting a tool instance."""
|
||||||
|
|
||||||
|
model_config = {"extra": "ignore"}
|
||||||
|
|
||||||
|
config_profile_id: str | None = Field(
|
||||||
|
default=None, description="Config profile ID to apply, or null for none"
|
||||||
|
)
|
||||||
|
ssh_key_ids: list[str] = Field(
|
||||||
|
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
|
||||||
|
)
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""Tool type request/response schemas."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
|
|
||||||
|
from src.api.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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""User schemas module."""
|
||||||
|
|
||||||
|
from src.schemas.user.user import UserProfileResponse, UserProfileUpdate
|
||||||
|
from src.schemas.user.user_config import UserConfigResponse, UserConfigUpdate
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"UserConfigResponse",
|
||||||
|
"UserConfigUpdate",
|
||||||
|
"UserProfileResponse",
|
||||||
|
"UserProfileUpdate",
|
||||||
|
]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Database seeding utilities."""
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""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.")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Config module."""
|
||||||
@@ -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.config_profile import ConfigProfile, ConfigProfileInclude
|
from src.models import ConfigProfile, ConfigProfileInclude
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""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)
|
||||||
@@ -1,181 +1,13 @@
|
|||||||
"""Docker service for managing tool instances."""
|
"""Docker container runtime queries and network management."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from collections import Counter
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def get_container_id(instance_name: str) -> str | None:
|
||||||
"""Get the container ID for a compose service.
|
"""Get the container ID for a compose service.
|
||||||
|
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""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),
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from src.models.workspace import Workspace
|
from src.models import Workspace
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Git module."""
|
||||||
@@ -4,7 +4,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from src.models.workspace import Workspace
|
from src.models import Workspace
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ 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.health_check import HealthCheck
|
from src.models import HealthCheck
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models import ToolInstance
|
||||||
from src.services.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 get_container_status
|
||||||
from src.services.tunnel import check_tunnel_health
|
from src.services.tunnel import check_tunnel_health
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Instance module."""
|
||||||
@@ -6,8 +6,8 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.models.instance_event import InstanceEvent
|
from src.models import InstanceEvent
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models import ToolInstance
|
||||||
from src.services.correlation import get_correlation_id
|
from src.services.correlation import get_correlation_id
|
||||||
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
||||||
from src.services.notification_service import notification_service
|
from src.services.notification_service import notification_service
|
||||||
|
|||||||
@@ -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.notification import Notification
|
from src.models import Notification
|
||||||
|
|
||||||
|
|
||||||
class NotificationService:
|
class NotificationService:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Shared module."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Terminal module."""
|
||||||
@@ -9,7 +9,7 @@ from fastapi import WebSocket
|
|||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
from src.models.terminal_session import TerminalSessionModel
|
from src.models import TerminalSessionModel
|
||||||
from src.services.terminal_session import TerminalSession
|
from src.services.terminal_session import TerminalSession
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -1,281 +1,8 @@
|
|||||||
"""Clean tunnel service using cloudflared containers on the backend network.
|
"""Tunnel service — re-exported from docker.tunnel for backward compatibility."""
|
||||||
|
|
||||||
Design:
|
from src.services.docker.tunnel import ( # noqa: F401
|
||||||
- Each tunnel runs as a Docker container on the same 'backend' network as the API.
|
check_tunnel_health,
|
||||||
- cloudflared connects to the tool container by its Docker Compose service name
|
recreate_tunnel,
|
||||||
(e.g. http://code-server-headquarter-34837cd3:8443).
|
start_tunnel,
|
||||||
- This avoids host port conflicts and DNS resolution issues.
|
stop_tunnel,
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from src.services.docker 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_container_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_container_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
|
|
||||||
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
|
||||||
start_time = __import__("time").time()
|
|
||||||
url: str | None = None
|
|
||||||
combined_logs = ""
|
|
||||||
|
|
||||||
while __import__("time").time() - start_time < timeout:
|
|
||||||
stdout, stderr = _get_container_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_container_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_container_logs(tunnel_name)
|
|
||||||
combined_logs = stdout + "\n" + stderr
|
|
||||||
exit_code = _get_container_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),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,15 +14,15 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from src.models.workspace import Workspace
|
from src.models import Workspace
|
||||||
from src.services.git_service import GitService
|
from src.services.git_service import GitService
|
||||||
from src.services.ssh_keys import _get_fernet
|
from src.services.ssh_keys import _get_fernet
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.models.git_repository import GitRepository
|
from src.models import GitRepository
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models import ToolInstance
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ class WorkspaceManager:
|
|||||||
# Load SSH key if repo has one
|
# Load SSH key if repo has one
|
||||||
ssh_key = None
|
ssh_key = None
|
||||||
if getattr(repo, "ssh_key_id", None) and session is not None:
|
if getattr(repo, "ssh_key_id", None) and session is not None:
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models import SSHKey
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(SSHKey).where(SSHKey.id == repo.ssh_key_id)
|
select(SSHKey).where(SSHKey.id == repo.ssh_key_id)
|
||||||
@@ -181,8 +181,8 @@ class WorkspaceManager:
|
|||||||
# Load SSH key if repo has one
|
# Load SSH key if repo has one
|
||||||
ssh_key = None
|
ssh_key = None
|
||||||
if session is not None:
|
if session is not None:
|
||||||
from src.models.git_repository import GitRepository
|
from src.models import GitRepository
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models import SSHKey
|
||||||
|
|
||||||
repo = await session.get(GitRepository, workspace.repo_id)
|
repo = await session.get(GitRepository, workspace.repo_id)
|
||||||
if repo and getattr(repo, "ssh_key_id", None):
|
if repo and getattr(repo, "ssh_key_id", None):
|
||||||
@@ -241,7 +241,7 @@ class WorkspaceManager:
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> list[ToolInstance]:
|
) -> list[ToolInstance]:
|
||||||
"""Get all tool instances associated with this workspace."""
|
"""Get all tool instances associated with this workspace."""
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models import ToolInstance
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ToolInstance).where(ToolInstance.workspace_id == workspace.id)
|
select(ToolInstance).where(ToolInstance.workspace_id == workspace.id)
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ services:
|
|||||||
- /data/repos:/data/repos
|
- /data/repos:/data/repos
|
||||||
- /data/working-copies:/data/working-copies
|
- /data/working-copies:/data/working-copies
|
||||||
- /data/instances:/data/instances
|
- /data/instances:/data/instances
|
||||||
|
- ./apps/api/src:/app/src:ro
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-03
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Current `dev` has all behavioral features from the overwritten `main` merge, but the code structure is pre-refactor:
|
||||||
|
- Monolithic `api/tool_instances.py` (~3000 lines)
|
||||||
|
- Monolithic `api/config_profiles.py` (~1000 lines)
|
||||||
|
- Monolithic `services/docker.py`
|
||||||
|
- No `schemas/` directory
|
||||||
|
- Flat frontend component structure with inconsistent naming
|
||||||
|
|
||||||
|
The `b6f89f9` merge from `main` had a clean refactoring that we need to redo, but adapted to our current reality.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Extract Pydantic schemas from API routers into `src/schemas/`
|
||||||
|
- Split `services/docker.py` into `services/docker/` package
|
||||||
|
- Extract instance lifecycle logic from `api/tool_instances.py` into `services/instance_lifecycle.py`
|
||||||
|
- Extract config profile business logic from `api/config_profiles.py` into `services/config_profiles.py`
|
||||||
|
- Add `get_current_user` auth dependency and migrate routers that need the full user object
|
||||||
|
- Reorganize frontend components into `features/` directories
|
||||||
|
- Standardize frontend API file naming to kebab-case
|
||||||
|
- Standardize frontend page naming to `*Page.tsx`
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Changing any API request/response shapes
|
||||||
|
- Changing any database schemas
|
||||||
|
- Adding new features
|
||||||
|
- Modifying frontend component behavior or styling
|
||||||
|
- Converting `ConfigProfile` mounts from JSON to relation tables (out of scope — would require migration)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 0. Submodule Rule: Max 5–10 Files Per Directory
|
||||||
|
**Decision:** Every directory that functions as a Python module must contain at most 5–10 `.py` files. When a module grows beyond this, split it into a package with submodules.
|
||||||
|
**Rationale:** Prevents monolithic directories, makes navigation predictable, and keeps cognitive load bounded.
|
||||||
|
|
||||||
|
### 1. Schema Extraction: Domain Subpackages
|
||||||
|
**Decision:** Extract Pydantic models into `schemas/` subpackages by domain:
|
||||||
|
- `schemas/tool/` — tool_type.py, tool_instance.py
|
||||||
|
- `schemas/config/` — config_profile.py
|
||||||
|
- `schemas/user/` — user.py, user_config.py
|
||||||
|
- `schemas/project/` — project.py, git_repository.py, ssh_key.py
|
||||||
|
- `schemas/system/` — health.py
|
||||||
|
**Rationale:** Keeps schemas close to their domain. Each subpackage has ≤5 files.
|
||||||
|
|
||||||
|
### 2. API Router Subpackages
|
||||||
|
**Decision:** Split `api/` into domain subpackages:
|
||||||
|
- `api/tool/` — tool_instances.py, tool_types.py, tool_definitions.py, tool_types_validation.py, sessions.py
|
||||||
|
- `api/config/` — config_profiles.py, user_config.py
|
||||||
|
- `api/workspace/` — workspaces.py, workspace_files.py, workspace_git.py, workspace_instances.py
|
||||||
|
- `api/user/` — users.py, auth.py, ssh_keys.py
|
||||||
|
- `api/project/` — projects.py, git_repositories.py
|
||||||
|
- `api/system/` — health.py, events.py, notifications.py, dashboard.py, terminal.py, instance_proxy.py
|
||||||
|
**Rationale:** `api/` currently has ~22 files. Splitting into 6 subpackages keeps each at 2–6 files.
|
||||||
|
|
||||||
|
### 3. Service Subpackages
|
||||||
|
**Decision:** Split `services/` into subpackages:
|
||||||
|
- `services/docker/` — compose.py, container.py, config_staging.py, tunnel.py, __init__.py
|
||||||
|
- `services/instance/` — instance_lifecycle.py, lifecycle_hooks.py, health_monitor.py, event_bus.py
|
||||||
|
- `services/config/` — config_profile_resolver.py, config_profiles.py
|
||||||
|
- `services/git/` — clone.py, git_operations.py, git_service.py
|
||||||
|
- `services/build/` — docker_build.py, manifest_compiler.py
|
||||||
|
- `services/terminal/` — terminal_manager.py, terminal_session.py
|
||||||
|
- `services/shared/` — tunnel.py, notification_service.py, file_service.py, permission_fixer.py, readiness_probe.py, ssh_keys.py, workspace_manager.py, correlation.py
|
||||||
|
**Rationale:** `services/` currently has ~20 files. Subpackages keep each at ≤8 files.
|
||||||
|
|
||||||
|
### 4. Model Subpackages
|
||||||
|
**Decision:** Split `models/` into subpackages:
|
||||||
|
- `models/tool/` — tool_type.py, tool_instance.py, tool_definition_manifest.py
|
||||||
|
- `models/config/` — config_profile.py, config_include.py, config_mount.py
|
||||||
|
- `models/user/` — user.py, user_config.py, ssh_key.py
|
||||||
|
- `models/project/` — project.py, git_repository.py, workspace.py
|
||||||
|
- `models/system/` — health_check.py, notification.py, instance_event.py, terminal_session.py
|
||||||
|
- `models/base.py` stays at root
|
||||||
|
**Rationale:** `models/` currently has ~15 files. Subpackages keep each at ≤4 files.
|
||||||
|
|
||||||
|
### 5. Docker Service Split: Functional Boundaries
|
||||||
|
**Decision:** Split by responsibility:
|
||||||
|
- `compose.py` — compose file generation, modification, port injection, network injection
|
||||||
|
- `container.py` — container status, IP lookup, network connect, logs
|
||||||
|
- `config_staging.py` — staging config files into instance directories
|
||||||
|
- `tunnel.py` — extracting tunnel URLs from cloudflared output
|
||||||
|
**Rationale:** Each module has a single reason to change. `docker.py` mixed compose logic with container runtime queries.
|
||||||
|
|
||||||
|
### 6. Instance Lifecycle: Service Receives Raw Params, Not Request Objects
|
||||||
|
**Decision:** Service functions receive model instances and primitive parameters, not FastAPI request objects.
|
||||||
|
**Example:** `create_instance(session, user, project, repo, tool_type, data: CreateInstanceRequest)` → service extracts fields.
|
||||||
|
**Rationale:** Keeps service layer independent of HTTP framework. Easier to test.
|
||||||
|
|
||||||
|
### 7. Auth Pattern: Gradual Migration, Not Big Bang
|
||||||
|
**Decision:** Add `get_current_user` alongside existing `get_current_user_id`. Migrate routers incrementally.
|
||||||
|
**Rationale:** Reduces risk. Endpoints that only need the ID can keep the old pattern.
|
||||||
|
|
||||||
|
### 8. Frontend Naming: Align with `b6f89f9` Conventions
|
||||||
|
**Decision:** Use kebab-case for API files, PascalCase for page files with `Page` suffix, `features/` for component directories.
|
||||||
|
**Rationale:** Matches the `b6f89f9` structure that was already reviewed and accepted.
|
||||||
|
|
||||||
|
## Module Map
|
||||||
|
|
||||||
|
### Backend — Before
|
||||||
|
```
|
||||||
|
api/ (~22 .py files)
|
||||||
|
tool_instances.py (~3000 lines) — HTTP + Docker + Git + Lifecycle
|
||||||
|
config_profiles.py (~1000 lines) — HTTP + Validation + Defaults
|
||||||
|
tool_types.py (~500 lines) — HTTP + Schemas
|
||||||
|
health.py (~150 lines) — HTTP + Schemas
|
||||||
|
users.py (~100 lines) — HTTP + Schemas
|
||||||
|
...
|
||||||
|
services/ (~20 .py files)
|
||||||
|
docker.py (~600 lines) — Compose + Container + Tunnel
|
||||||
|
models/ (~15 .py files)
|
||||||
|
config_profile.py
|
||||||
|
tool_instance.py
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend — After
|
||||||
|
```
|
||||||
|
schemas/ (5 subpackages, ≤5 files each)
|
||||||
|
tool/
|
||||||
|
__init__.py
|
||||||
|
tool_type.py
|
||||||
|
tool_instance.py
|
||||||
|
config/
|
||||||
|
__init__.py
|
||||||
|
config_profile.py
|
||||||
|
user/
|
||||||
|
__init__.py
|
||||||
|
user.py
|
||||||
|
user_config.py
|
||||||
|
project/
|
||||||
|
__init__.py
|
||||||
|
project.py
|
||||||
|
git_repository.py
|
||||||
|
ssh_key.py
|
||||||
|
system/
|
||||||
|
__init__.py
|
||||||
|
health.py
|
||||||
|
|
||||||
|
api/ (6 subpackages, 2–6 files each)
|
||||||
|
tool/
|
||||||
|
__init__.py
|
||||||
|
tool_instances.py (~300 lines) — HTTP routing only
|
||||||
|
tool_types.py (~250 lines) — HTTP + validation
|
||||||
|
tool_definitions.py
|
||||||
|
tool_types_validation.py
|
||||||
|
sessions.py
|
||||||
|
config/
|
||||||
|
__init__.py
|
||||||
|
config_profiles.py (~200 lines) — HTTP routing only
|
||||||
|
user_config.py
|
||||||
|
workspace/
|
||||||
|
__init__.py
|
||||||
|
workspaces.py
|
||||||
|
workspace_files.py
|
||||||
|
workspace_git.py
|
||||||
|
workspace_instances.py
|
||||||
|
user/
|
||||||
|
__init__.py
|
||||||
|
users.py (~60 lines) — HTTP only
|
||||||
|
auth.py
|
||||||
|
ssh_keys.py
|
||||||
|
project/
|
||||||
|
__init__.py
|
||||||
|
projects.py
|
||||||
|
git_repositories.py
|
||||||
|
system/
|
||||||
|
__init__.py
|
||||||
|
health.py (~80 lines) — HTTP only
|
||||||
|
events.py
|
||||||
|
notifications.py
|
||||||
|
dashboard.py
|
||||||
|
terminal.py
|
||||||
|
instance_proxy.py
|
||||||
|
|
||||||
|
services/ (7 subpackages, ≤8 files each)
|
||||||
|
docker/
|
||||||
|
__init__.py (~40 lines) — Re-exports
|
||||||
|
compose.py (~240 lines) — Compose generation
|
||||||
|
container.py (~120 lines) — Container queries
|
||||||
|
config_staging.py (~80 lines) — File staging
|
||||||
|
tunnel.py (~150 lines) — Tunnel URL extraction
|
||||||
|
instance/
|
||||||
|
__init__.py
|
||||||
|
instance_lifecycle.py (~420 lines) — Create/Start/Stop/Restart/Delete
|
||||||
|
lifecycle_hooks.py
|
||||||
|
health_monitor.py
|
||||||
|
event_bus.py
|
||||||
|
config/
|
||||||
|
__init__.py
|
||||||
|
config_profile_resolver.py
|
||||||
|
config_profiles.py (~300 lines) — CRUD + Defaults
|
||||||
|
git/
|
||||||
|
__init__.py
|
||||||
|
clone.py
|
||||||
|
git_operations.py
|
||||||
|
git_service.py
|
||||||
|
build/
|
||||||
|
__init__.py
|
||||||
|
docker_build.py
|
||||||
|
manifest_compiler.py
|
||||||
|
terminal/
|
||||||
|
__init__.py
|
||||||
|
terminal_manager.py
|
||||||
|
terminal_session.py
|
||||||
|
shared/
|
||||||
|
__init__.py
|
||||||
|
tunnel.py
|
||||||
|
notification_service.py
|
||||||
|
file_service.py
|
||||||
|
permission_fixer.py
|
||||||
|
readiness_probe.py
|
||||||
|
ssh_keys.py
|
||||||
|
workspace_manager.py
|
||||||
|
correlation.py
|
||||||
|
|
||||||
|
models/ (5 subpackages + base.py)
|
||||||
|
tool/
|
||||||
|
__init__.py
|
||||||
|
tool_type.py
|
||||||
|
tool_instance.py
|
||||||
|
tool_definition_manifest.py
|
||||||
|
config/
|
||||||
|
__init__.py
|
||||||
|
config_profile.py
|
||||||
|
config_include.py
|
||||||
|
config_mount.py
|
||||||
|
user/
|
||||||
|
__init__.py
|
||||||
|
user.py
|
||||||
|
user_config.py
|
||||||
|
ssh_key.py
|
||||||
|
project/
|
||||||
|
__init__.py
|
||||||
|
project.py
|
||||||
|
git_repository.py
|
||||||
|
workspace.py
|
||||||
|
system/
|
||||||
|
__init__.py
|
||||||
|
health_check.py
|
||||||
|
notification.py
|
||||||
|
instance_event.py
|
||||||
|
terminal_session.py
|
||||||
|
base.py (stays at root)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend — Before
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
api/
|
||||||
|
tool_types.ts
|
||||||
|
ssh_keys.ts
|
||||||
|
git_repositories.ts
|
||||||
|
sessions.ts
|
||||||
|
components/
|
||||||
|
git-toolbar.tsx
|
||||||
|
file-editor.tsx
|
||||||
|
commit-dialog.tsx
|
||||||
|
...
|
||||||
|
pages/
|
||||||
|
dashboard.tsx
|
||||||
|
projects.tsx
|
||||||
|
sessions.tsx
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend — After
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
api/
|
||||||
|
tool-types.ts
|
||||||
|
ssh-keys.ts
|
||||||
|
git-repositories.ts
|
||||||
|
sessions.ts
|
||||||
|
components/
|
||||||
|
features/
|
||||||
|
git/
|
||||||
|
GitToolbar.tsx
|
||||||
|
FileBrowser.tsx
|
||||||
|
FileEditor.tsx
|
||||||
|
CommitDialog.tsx
|
||||||
|
MergeDialog.tsx
|
||||||
|
WorkspaceSidebar.tsx
|
||||||
|
dashboard/
|
||||||
|
DashboardSummary.tsx
|
||||||
|
ActiveSessionsList.tsx
|
||||||
|
ProjectsSection.tsx
|
||||||
|
QuickCreateForm.tsx
|
||||||
|
RecentSessionsSection.tsx
|
||||||
|
project/
|
||||||
|
RepositoriesSettingsTab.tsx
|
||||||
|
tool-workshop/
|
||||||
|
ToolTypesTab.tsx
|
||||||
|
ProtectedRoute.tsx
|
||||||
|
AppShell.tsx
|
||||||
|
pages/
|
||||||
|
DashboardPage.tsx
|
||||||
|
ProjectsPage.tsx
|
||||||
|
SessionsPage.tsx
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
**[Risk] Import cycles during extraction** → **Mitigation:** Extract schemas first (no service dependencies), then services, then thin routers last. Use TYPE_CHECKING guards.
|
||||||
|
|
||||||
|
**[Risk] Merge conflicts with in-flight features** → **Mitigation:** Coordinate timing. This refactor should be the only large change on `dev` while it's in progress. Freeze other backend work.
|
||||||
|
|
||||||
|
**[Risk] Frontend renaming breaks imports** → **Mitigation:** Use `git mv` for renames so git tracks history. Update all imports in a single commit.
|
||||||
|
|
||||||
|
**[Risk] Missing re-export in docker/__init__.py breaks consumers** → **Mitigation:** After splitting, run a full import test across all backend files. Add any missing re-exports.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. **Phase 1: Schemas** — Extract all Pydantic models into `schemas/`. Update imports in API routers. No logic changes.
|
||||||
|
2. **Phase 2: Services** — Split `docker.py`, extract `instance_lifecycle.py`, extract `config_profiles.py`. Update imports.
|
||||||
|
3. **Phase 3: Auth** — Add `get_current_user`, migrate routers that need the full user object.
|
||||||
|
4. **Phase 4: Frontend** — Rename files, move components, update imports.
|
||||||
|
5. **Phase 5: Verification** — Run full test suite, typecheck, build.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The `main` branch was previously merged into `dev` (commit `b6f89f9`) bringing a large structural refactoring: schema extraction, service splitting, auth dependency pattern changes, and frontend reorganization. This merge was later overwritten when `dev` was reset to a pre-merge clean state (`51a399c`).
|
||||||
|
|
||||||
|
We have since forward-ported all behavioral features (built-in tool type seeding, config profile defaults, unique constraints, SSH key mounting, terminal backend, etc.) onto the clean `dev` base. **The codebase now works functionally but lacks the structural cleanliness of the refactoring.**
|
||||||
|
|
||||||
|
Monolithic files make the backend harder to navigate, test, and maintain:
|
||||||
|
- `api/tool_instances.py` is ~3000 lines (mixing HTTP handling with Docker orchestration)
|
||||||
|
- `api/config_profiles.py` is ~1000 lines (mixing HTTP handling with business logic)
|
||||||
|
- `services/docker.py` is a monolith of ~600 lines covering compose, container, and tunnel logic
|
||||||
|
- Frontend API files use inconsistent naming (`tool_types.ts` vs `tool-types.ts`)
|
||||||
|
- Frontend components are flat in `components/` instead of organized by feature domain
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
Redo the structural refactoring from `b6f89f9`, **adapted to current dev reality**:
|
||||||
|
|
||||||
|
1. **Backend schema extraction** — Extract Pydantic request/response schemas from API routers into `apps/api/src/schemas/`
|
||||||
|
2. **Backend service splits** — Split `services/docker.py` into `services/docker/` package; extract `services/instance_lifecycle.py` and `services/config_profiles.py`
|
||||||
|
3. **Auth dependency refactor** — Change from `get_current_user_id` + manual `_get_user` calls to `get_current_user` dependency returning `User` directly
|
||||||
|
4. **Frontend reorganization** — Move components into `features/` directories; rename API files to kebab-case
|
||||||
|
|
||||||
|
**No behavioral changes.** This is a pure structural refactor. All existing endpoints, models, and UI flows remain identical.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- None (pure refactor)
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `backend-structure`: Cleaner module boundaries, smaller files, separated concerns
|
||||||
|
- `frontend-structure`: Feature-organized components, consistent file naming
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Backend**: `apps/api/src/schemas/*` (new), `apps/api/src/services/docker/*` (new package), `apps/api/src/services/instance_lifecycle.py` (new), `apps/api/src/services/config_profiles.py` (new)
|
||||||
|
- **Backend**: `apps/api/src/api/*.py` (reduced in size, imports change)
|
||||||
|
- **Backend**: `apps/api/src/auth/dependencies.py` (new `get_current_user`)
|
||||||
|
- **Frontend**: `apps/web/src/components/features/*` (new directories), `apps/web/src/api/*` (renamed to kebab-case)
|
||||||
|
- **Frontend**: `apps/web/src/pages/*` (renamed to `*Page.tsx`)
|
||||||
|
|
||||||
|
## Exclusions (Already Done)
|
||||||
|
|
||||||
|
The following behavioral features from `b6f89f9` are **already present** in current `dev` and out of scope for this refactor:
|
||||||
|
- Built-in tool type seeding (`src/seeds/builtin_tool_types.py`)
|
||||||
|
- Config profile default management (endpoints + `UserConfig` properties)
|
||||||
|
- Config profile unique constraint (`uq_config_profiles_user_name`)
|
||||||
|
- SSH key mounting in instance lifecycle
|
||||||
|
- Terminal backend (WebSocket, session management)
|
||||||
|
- Tunnel URL regex fix
|
||||||
|
- Session auto-numbering
|
||||||
|
- Config profile resolver (`services/config_profile_resolver.py`)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
## Scope
|
||||||
|
|
||||||
|
This change is a **pure structural refactoring** of the backend and frontend codebase. No API contracts, database schemas, or user-facing behaviors change.
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
|
||||||
|
1. **Schema extraction** (`apps/api/src/schemas/`)
|
||||||
|
- Extract Pydantic models from API routers into dedicated schema modules
|
||||||
|
- Schemas to extract: `tool_type`, `tool_instance`, `config_profile`, `user`, `user_config`, `project`, `ssh_key`, `git_repository`, `health`
|
||||||
|
- Each API router imports schemas from `src.schemas.*` instead of defining inline
|
||||||
|
|
||||||
|
2. **Docker service package** (`apps/api/src/services/docker/`)
|
||||||
|
- Split `services/docker.py` monolith into focused modules:
|
||||||
|
- `docker/compose.py` — compose file generation, modification, validation
|
||||||
|
- `docker/container.py` — container lifecycle (status, IP, network, logs)
|
||||||
|
- `docker/config_staging.py` — config file staging for instances
|
||||||
|
- `docker/tunnel.py` — tunnel URL extraction (moved from `services/tunnel.py`)
|
||||||
|
- `docker/__init__.py` — re-exports for backward compatibility
|
||||||
|
- Update all imports across the backend
|
||||||
|
|
||||||
|
3. **Instance lifecycle extraction** (`apps/api/src/services/instance_lifecycle.py`)
|
||||||
|
- Extract instance creation, start, stop, restart, and deletion logic from `api/tool_instances.py`
|
||||||
|
- The API router becomes thin: validates auth, calls service, returns response
|
||||||
|
- Service functions are async and receive `AsyncSession`, models, and raw parameters
|
||||||
|
|
||||||
|
4. **Config profile service extraction** (`apps/api/src/services/config_profiles.py`)
|
||||||
|
- Extract business logic from `api/config_profiles.py`: CRUD helpers, validation, default profile management
|
||||||
|
- API router delegates to service functions
|
||||||
|
|
||||||
|
5. **Auth dependency refactor** (`apps/api/src/auth/dependencies.py`)
|
||||||
|
- Add `get_current_user` dependency that returns a `User` model directly
|
||||||
|
- Update API routers to use `user: User = Depends(get_current_user)` where the full user object is needed
|
||||||
|
- Keep `get_current_user_id` for endpoints that only need the ID
|
||||||
|
|
||||||
|
6. **Frontend reorganization**
|
||||||
|
- Move components into `features/` directories by domain:
|
||||||
|
- `features/git/` — CommitDialog, FileBrowser, FileEditor, GitToolbar, MergeDialog, WorkspaceSidebar
|
||||||
|
- `features/dashboard/` — ActiveSessionsList, DashboardSummary, ProjectsSection, QuickCreateForm, RecentSessionsSection
|
||||||
|
- `features/project/` — RepositoriesSettingsTab
|
||||||
|
- `features/tool-workshop/` — ToolTypesTab (already exists)
|
||||||
|
- Rename API files from snake_case to kebab-case:
|
||||||
|
- `tool_types.ts` → `tool-types.ts`
|
||||||
|
- `ssh_keys.ts` → `ssh-keys.ts`
|
||||||
|
- `git_repositories.ts` → `git-repositories.ts`
|
||||||
|
- Rename page files to `*Page.tsx`:
|
||||||
|
- `dashboard.tsx` → `DashboardPage.tsx`
|
||||||
|
- `projects.tsx` → `ProjectsPage.tsx`
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
|
||||||
|
- Any new features or behavioral changes
|
||||||
|
- Database schema changes (no migrations)
|
||||||
|
- API contract changes (same endpoints, same request/response shapes)
|
||||||
|
- Frontend UI behavior changes (same components, same interactions)
|
||||||
|
- Removing or modifying the `ConfigProfileInclude` model (already exists as a relation)
|
||||||
|
- Extracting `ConfigMount` into a separate model (current dev uses JSON arrays; this is a schema decision, not a refactor)
|
||||||
|
- Changes to `tool_definition_manifest` or `workspace` models
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. All existing tests pass without modification (behavior unchanged)
|
||||||
|
2. All existing API endpoints return identical responses for identical requests
|
||||||
|
3. All frontend pages render identically
|
||||||
|
4. `docker compose up` starts successfully
|
||||||
|
5. Backend `ruff check` passes
|
||||||
|
6. Frontend `npm run typecheck` passes
|
||||||
|
7. Frontend `npm run build` passes
|
||||||
|
8. File sizes are reduced: no API router > 500 lines, no service > 400 lines
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
- Current `dev` branch is stable and all behavioral forward-ports are complete
|
||||||
|
- All legacy `ToolConfig`/`ConfigFolder` code has been removed
|
||||||
|
- Database migrations are at a single head
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
## Phase 0: Submodule Infrastructure (Create Directories + __init__.py Files)
|
||||||
|
|
||||||
|
- [ ] 0.1 Create `apps/api/src/schemas/tool/__init__.py`
|
||||||
|
- [ ] 0.2 Create `apps/api/src/schemas/config/__init__.py`
|
||||||
|
- [ ] 0.3 Create `apps/api/src/schemas/user/__init__.py`
|
||||||
|
- [ ] 0.4 Create `apps/api/src/schemas/project/__init__.py`
|
||||||
|
- [ ] 0.5 Create `apps/api/src/schemas/system/__init__.py`
|
||||||
|
- [ ] 0.6 Create `apps/api/src/api/tool/__init__.py`
|
||||||
|
- [ ] 0.7 Create `apps/api/src/api/config/__init__.py`
|
||||||
|
- [ ] 0.8 Create `apps/api/src/api/workspace/__init__.py`
|
||||||
|
- [ ] 0.9 Create `apps/api/src/api/user/__init__.py`
|
||||||
|
- [ ] 0.10 Create `apps/api/src/api/project/__init__.py`
|
||||||
|
- [ ] 0.11 Create `apps/api/src/api/system/__init__.py`
|
||||||
|
- [ ] 0.12 Create `apps/api/src/services/instance/__init__.py`
|
||||||
|
- [ ] 0.13 Create `apps/api/src/services/config/__init__.py`
|
||||||
|
- [ ] 0.14 Create `apps/api/src/services/git/__init__.py`
|
||||||
|
- [ ] 0.15 Create `apps/api/src/services/build/__init__.py`
|
||||||
|
- [ ] 0.16 Create `apps/api/src/services/terminal/__init__.py`
|
||||||
|
- [ ] 0.17 Create `apps/api/src/services/shared/__init__.py`
|
||||||
|
- [ ] 0.18 Create `apps/api/src/models/tool/__init__.py`
|
||||||
|
- [ ] 0.19 Create `apps/api/src/models/config/__init__.py`
|
||||||
|
- [ ] 0.20 Create `apps/api/src/models/user/__init__.py`
|
||||||
|
- [ ] 0.21 Create `apps/api/src/models/project/__init__.py`
|
||||||
|
- [ ] 0.22 Create `apps/api/src/models/system/__init__.py`
|
||||||
|
|
||||||
|
## Phase 1: Model Subpackages
|
||||||
|
|
||||||
|
- [ ] 1.1 Move `models/tool_type.py` → `models/tool/tool_type.py`
|
||||||
|
- [ ] 1.2 Move `models/tool_instance.py` → `models/tool/tool_instance.py`
|
||||||
|
- [ ] 1.3 Move `models/tool_definition_manifest.py` → `models/tool/tool_definition_manifest.py`
|
||||||
|
- [ ] 1.4 Move `models/config_profile.py` → `models/config/config_profile.py`
|
||||||
|
- [ ] 1.5 Move `models/config_include.py` (if exists) → `models/config/config_include.py`
|
||||||
|
- [ ] 1.6 Move `models/config_mount.py` (if exists) → `models/config/config_mount.py`
|
||||||
|
- [ ] 1.7 Move `models/user.py` → `models/user/user.py`
|
||||||
|
- [ ] 1.8 Move `models/user_config.py` → `models/user/user_config.py`
|
||||||
|
- [ ] 1.9 Move `models/ssh_key.py` → `models/user/ssh_key.py`
|
||||||
|
- [ ] 1.10 Move `models/project.py` → `models/project/project.py`
|
||||||
|
- [ ] 1.11 Move `models/git_repository.py` → `models/project/git_repository.py`
|
||||||
|
- [ ] 1.12 Move `models/workspace.py` → `models/project/workspace.py`
|
||||||
|
- [ ] 1.13 Move `models/health_check.py` → `models/system/health_check.py`
|
||||||
|
- [ ] 1.14 Move `models/notification.py` → `models/system/notification.py`
|
||||||
|
- [ ] 1.15 Move `models/instance_event.py` → `models/system/instance_event.py`
|
||||||
|
- [ ] 1.16 Move `models/terminal_session.py` → `models/system/terminal_session.py`
|
||||||
|
- [ ] 1.17 Update `models/__init__.py` to import from subpackages
|
||||||
|
- [ ] 1.18 Update all backend imports to use `models.tool.tool_type` etc.
|
||||||
|
- [ ] 1.19 Verify `py_compile` and `ruff` pass
|
||||||
|
|
||||||
|
## Phase 2: Schema Extraction + Subpackages
|
||||||
|
|
||||||
|
- [ ] 2.1 Extract `schemas/tool/tool_type.py` from `api/tool_types.py`
|
||||||
|
- [ ] 2.2 Extract `schemas/tool/tool_instance.py` from `api/tool_instances.py`
|
||||||
|
- [ ] 2.3 Extract `schemas/config/config_profile.py` from `api/config_profiles.py`
|
||||||
|
- [ ] 2.4 Extract `schemas/system/health.py` from `api/health.py`
|
||||||
|
- [ ] 2.5 Extract `schemas/user/user.py` from `api/users.py`
|
||||||
|
- [ ] 2.6 Extract `schemas/user/user_config.py` from `api/user_config.py`
|
||||||
|
- [ ] 2.7 Extract `schemas/project/project.py` from `api/projects.py`
|
||||||
|
- [ ] 2.8 Extract `schemas/project/ssh_key.py` from `api/ssh_keys.py`
|
||||||
|
- [ ] 2.9 Extract `schemas/project/git_repository.py` from `api/git_repositories.py`
|
||||||
|
- [ ] 2.10 Update all API routers to import schemas from `src.schemas.*`
|
||||||
|
- [ ] 2.11 Verify `py_compile` and `ruff` pass
|
||||||
|
|
||||||
|
## Phase 3: Docker Service Package Split
|
||||||
|
|
||||||
|
- [ ] 3.1 Create `services/docker/__init__.py` with re-exports
|
||||||
|
- [ ] 3.2 Create `services/docker/compose.py` from `services/docker.py`
|
||||||
|
- [ ] 3.3 Create `services/docker/container.py` from `services/docker.py`
|
||||||
|
- [ ] 3.4 Create `services/docker/config_staging.py` from `services/docker.py`
|
||||||
|
- [ ] 3.5 Create `services/docker/tunnel.py` from `services/tunnel.py`
|
||||||
|
- [ ] 3.6 Remove `services/docker.py` after verifying imports
|
||||||
|
- [ ] 3.7 Update `services/tunnel.py` or remove if subsumed
|
||||||
|
- [ ] 3.8 Update all consumers to import from `services.docker`
|
||||||
|
- [ ] 3.9 Verify `py_compile` and `ruff` pass
|
||||||
|
|
||||||
|
## Phase 4: Service Subpackages
|
||||||
|
|
||||||
|
- [ ] 4.1 Move `services/lifecycle_hooks.py` → `services/instance/lifecycle_hooks.py`
|
||||||
|
- [ ] 4.2 Move `services/health_monitor.py` → `services/instance/health_monitor.py`
|
||||||
|
- [ ] 4.3 Move `services/event_bus.py` → `services/instance/event_bus.py`
|
||||||
|
- [ ] 4.4 Move `services/config_profile_resolver.py` → `services/config/config_profile_resolver.py`
|
||||||
|
- [ ] 4.5 Move `services/clone.py` → `services/git/clone.py`
|
||||||
|
- [ ] 4.6 Move `services/git_operations.py` → `services/git/git_operations.py`
|
||||||
|
- [ ] 4.7 Move `services/git_service.py` → `services/git/git_service.py`
|
||||||
|
- [ ] 4.8 Move `services/docker_build.py` → `services/build/docker_build.py`
|
||||||
|
- [ ] 4.9 Move `services/manifest_compiler.py` → `services/build/manifest_compiler.py`
|
||||||
|
- [ ] 4.10 Move `services/terminal_manager.py` → `services/terminal/terminal_manager.py`
|
||||||
|
- [ ] 4.11 Move `services/terminal_session.py` → `services/terminal/terminal_session.py`
|
||||||
|
- [ ] 4.12 Move `services/tunnel.py` → `services/shared/tunnel.py`
|
||||||
|
- [ ] 4.13 Move `services/notification_service.py` → `services/shared/notification_service.py`
|
||||||
|
- [ ] 4.14 Move `services/file_service.py` → `services/shared/file_service.py`
|
||||||
|
- [ ] 4.15 Move `services/permission_fixer.py` → `services/shared/permission_fixer.py`
|
||||||
|
- [ ] 4.16 Move `services/readiness_probe.py` → `services/shared/readiness_probe.py`
|
||||||
|
- [ ] 4.17 Move `services/ssh_keys.py` → `services/shared/ssh_keys.py`
|
||||||
|
- [ ] 4.18 Move `services/workspace_manager.py` → `services/shared/workspace_manager.py`
|
||||||
|
- [ ] 4.19 Move `services/correlation.py` → `services/shared/correlation.py`
|
||||||
|
- [ ] 4.20 Extract `services/instance/instance_lifecycle.py` from `api/tool_instances.py`
|
||||||
|
- [ ] 4.21 Extract `services/config/config_profiles.py` from `api/config_profiles.py`
|
||||||
|
- [ ] 4.22 Update all imports across the backend
|
||||||
|
- [ ] 4.23 Verify `py_compile` and `ruff` pass
|
||||||
|
|
||||||
|
## Phase 5: API Router Subpackages
|
||||||
|
|
||||||
|
- [ ] 5.1 Move `api/tool_instances.py` → `api/tool/tool_instances.py`
|
||||||
|
- [ ] 5.2 Move `api/tool_types.py` → `api/tool/tool_types.py`
|
||||||
|
- [ ] 5.3 Move `api/tool_definitions.py` → `api/tool/tool_definitions.py`
|
||||||
|
- [ ] 5.4 Move `api/tool_types_validation.py` → `api/tool/tool_types_validation.py`
|
||||||
|
- [ ] 5.5 Move sessions_router from `api/tool_instances.py` → `api/tool/sessions.py`
|
||||||
|
- [ ] 5.6 Move `api/config_profiles.py` → `api/config/config_profiles.py`
|
||||||
|
- [ ] 5.7 Move `api/user_config.py` → `api/config/user_config.py`
|
||||||
|
- [ ] 5.8 Move `api/workspaces.py` → `api/workspace/workspaces.py`
|
||||||
|
- [ ] 5.9 Move `api/workspace_files.py` → `api/workspace/workspace_files.py`
|
||||||
|
- [ ] 5.10 Move `api/workspace_git.py` → `api/workspace/workspace_git.py`
|
||||||
|
- [ ] 5.11 Move `api/workspace_instances.py` → `api/workspace/workspace_instances.py`
|
||||||
|
- [ ] 5.12 Move `api/users.py` → `api/user/users.py`
|
||||||
|
- [ ] 5.13 Move `api/auth.py` → `api/user/auth.py`
|
||||||
|
- [ ] 5.14 Move `api/ssh_keys.py` → `api/user/ssh_keys.py`
|
||||||
|
- [ ] 5.15 Move `api/projects.py` → `api/project/projects.py`
|
||||||
|
- [ ] 5.16 Move `api/git_repositories.py` → `api/project/git_repositories.py`
|
||||||
|
- [ ] 5.17 Move `api/health.py` → `api/system/health.py`
|
||||||
|
- [ ] 5.18 Move `api/events.py` → `api/system/events.py`
|
||||||
|
- [ ] 5.19 Move `api/notifications.py` → `api/system/notifications.py`
|
||||||
|
- [ ] 5.20 Move `api/dashboard.py` → `api/system/dashboard.py`
|
||||||
|
- [ ] 5.21 Move `api/terminal.py` → `api/system/terminal.py`
|
||||||
|
- [ ] 5.22 Move `api/instance_proxy.py` → `api/system/instance_proxy.py`
|
||||||
|
- [ ] 5.23 Update `main.py` to import from subpackages
|
||||||
|
- [ ] 5.24 Update all cross-router imports
|
||||||
|
- [ ] 5.25 Verify `py_compile` and `ruff` pass
|
||||||
|
|
||||||
|
## Phase 6: Auth Dependency Refactor
|
||||||
|
|
||||||
|
- [ ] 6.1 Add `get_current_user` to `auth/dependencies.py`
|
||||||
|
- [ ] 6.2 Migrate `api/user/users.py` to use `get_current_user`
|
||||||
|
- [ ] 6.3 Migrate `api/tool/sessions.py` to use `get_current_user`
|
||||||
|
- [ ] 6.4 Migrate other routers incrementally
|
||||||
|
- [ ] 6.5 Verify `py_compile` and `ruff` pass
|
||||||
|
|
||||||
|
## Phase 7: Frontend Reorganization
|
||||||
|
|
||||||
|
- [ ] 7.1 Rename API files to kebab-case
|
||||||
|
- [ ] 7.2 Rename page files to `*Page.tsx`
|
||||||
|
- [ ] 7.3 Move components into `features/` directories
|
||||||
|
- [ ] 7.4 Update `router.tsx`
|
||||||
|
- [ ] 7.5 Verify `npm run typecheck` passes
|
||||||
|
- [ ] 7.6 Verify `npm run build` passes
|
||||||
|
|
||||||
|
## Phase 8: Integration and Verification
|
||||||
|
|
||||||
|
- [ ] 8.1 Run backend tests: `docker exec hq-api pytest`
|
||||||
|
- [ ] 8.2 Run backend lint: `ruff check`
|
||||||
|
- [ ] 8.3 Run frontend typecheck: `npm run typecheck`
|
||||||
|
- [ ] 8.4 Run frontend build: `npm run build`
|
||||||
|
- [ ] 8.5 Run `docker compose up --build` and verify API starts
|
||||||
|
- [ ] 8.6 Verify key user flows manually
|
||||||
|
- [ ] 8.7 Verify no 404s or import errors in browser console
|
||||||
|
|
||||||
|
## Phase 9: Documentation
|
||||||
|
|
||||||
|
- [ ] 9.1 Update `AGENTS.md` with new module structure
|
||||||
|
- [ ] 9.2 Document `get_current_user` vs `get_current_user_id` pattern
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
# Progress
|
||||||
|
|
||||||
|
## Status
|
||||||
|
In Progress — Backend structural refactoring
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
- [x] Phase 0: Create submodule directory structure (__init__.py files)
|
||||||
|
- [x] Phase 1: Model subpackages (tool, config, user, project, system)
|
||||||
|
- [x] Phase 2: Docker service package split (compose, container, config_staging, tunnel)
|
||||||
|
- [ ] Phase 3: Service subpackages (instance, config, git, build, terminal, shared)
|
||||||
|
- [ ] Phase 4: Schema extraction (tool, config, user, project, system)
|
||||||
|
- [ ] Phase 5: API router subpackages (tool, config, workspace, user, project, system)
|
||||||
|
- [ ] Phase 6: Auth dependency refactor
|
||||||
|
- [ ] Phase 7: Frontend reorganization
|
||||||
|
- [ ] Phase 8: Integration and verification
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
- `apps/api/src/services/docker.py` → deleted (split into package)
|
||||||
|
- `apps/api/src/services/docker/__init__.py` — re-exports all public functions
|
||||||
|
- `apps/api/src/services/docker/compose.py` — compose generation & commands
|
||||||
|
- `apps/api/src/services/docker/container.py` — container runtime queries
|
||||||
|
- `apps/api/src/services/docker/config_staging.py` — file staging into instances
|
||||||
|
- `apps/api/src/services/docker/tunnel.py` — cloudflared tunnel lifecycle
|
||||||
|
- `apps/api/src/services/tunnel.py` — thin backward-compat re-export wrapper
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- All backward-compatible imports preserved via __init__.py re-exports
|
||||||
|
- `from src.services.docker import X` continues to work for all previously exported symbols
|
||||||
|
- `from src.services.tunnel import X` continues to work via re-export wrapper
|
||||||
|
- No behavior changes, pure structural refactor
|
||||||
Reference in New Issue
Block a user