Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev
This commit is contained in:
@@ -1 +1,43 @@
|
|||||||
{"blockedId":"el-1of","blockerId":"el-258","type":"parent-child","createdAt":"2026-05-24T09:44:58.759Z","createdBy":"el-2jua"}
|
{"blockedId":"el-1of","blockerId":"el-258","type":"parent-child","createdAt":"2026-05-24T09:44:58.759Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5fe","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:40.892Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1nj","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.010Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1bn","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.127Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4hr","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.244Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-62c","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.372Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5z8","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.490Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1t7","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.607Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5j5","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.726Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-2xl","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.844Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4bc","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.959Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-107","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.074Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-32e","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.195Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-3ou","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.311Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-14w","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.425Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1ou","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.541Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1nj","blockerId":"el-5fe","type":"blocks","createdAt":"2026-05-24T12:44:42.651Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1bn","blockerId":"el-5fe","type":"blocks","createdAt":"2026-05-24T12:44:42.761Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4hr","blockerId":"el-5fe","type":"blocks","createdAt":"2026-05-24T12:44:42.868Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-62c","blockerId":"el-1nj","type":"blocks","createdAt":"2026-05-24T12:44:42.979Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-62c","blockerId":"el-1bn","type":"blocks","createdAt":"2026-05-24T12:44:43.092Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5z8","blockerId":"el-1nj","type":"blocks","createdAt":"2026-05-24T12:44:43.205Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5z8","blockerId":"el-4hr","type":"blocks","createdAt":"2026-05-24T12:44:43.313Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1t7","blockerId":"el-1bn","type":"blocks","createdAt":"2026-05-24T12:44:43.422Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1t7","blockerId":"el-4hr","type":"blocks","createdAt":"2026-05-24T12:44:43.529Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1t7","blockerId":"el-62c","type":"blocks","createdAt":"2026-05-24T12:44:43.647Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5j5","blockerId":"el-1t7","type":"blocks","createdAt":"2026-05-24T12:44:43.758Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-2xl","blockerId":"el-1t7","type":"blocks","createdAt":"2026-05-24T12:44:43.876Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4bc","blockerId":"el-1nj","type":"blocks","createdAt":"2026-05-24T12:44:43.987Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4bc","blockerId":"el-1bn","type":"blocks","createdAt":"2026-05-24T12:44:44.096Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4bc","blockerId":"el-62c","type":"blocks","createdAt":"2026-05-24T12:44:44.208Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-107","blockerId":"el-5z8","type":"blocks","createdAt":"2026-05-24T12:44:44.319Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-32e","blockerId":"el-5j5","type":"blocks","createdAt":"2026-05-24T12:44:44.429Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-32e","blockerId":"el-2xl","type":"blocks","createdAt":"2026-05-24T12:44:44.539Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-3ou","blockerId":"el-4bc","type":"blocks","createdAt":"2026-05-24T12:44:44.650Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-3ou","blockerId":"el-107","type":"blocks","createdAt":"2026-05-24T12:44:44.761Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-14w","blockerId":"el-32e","type":"blocks","createdAt":"2026-05-24T12:44:44.873Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1ou","blockerId":"el-3ou","type":"blocks","createdAt":"2026-05-24T12:44:44.987Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1ou","blockerId":"el-14w","type":"blocks","createdAt":"2026-05-24T12:44:45.107Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-375","blockerId":"el-26p","type":"replies-to","createdAt":"2026-05-24T13:21:42.486Z","createdBy":"el-2i1s"}
|
||||||
|
{"blockedId":"el-3n4","blockerId":"el-31p","type":"replies-to","createdAt":"2026-05-24T13:21:46.044Z","createdBy":"el-13ju"}
|
||||||
|
{"blockedId":"el-3jer","blockerId":"el-1xx","type":"replies-to","createdAt":"2026-05-24T13:24:47.580Z","createdBy":"el-4350"}
|
||||||
|
{"blockedId":"el-1afv","blockerId":"el-1ozw","type":"replies-to","createdAt":"2026-05-24T13:32:42.658Z","createdBy":"el-51a8"}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,86 @@
|
|||||||
|
"""add_config_profiles
|
||||||
|
|
||||||
|
Revision ID: 2026_05_24_add_config_profiles
|
||||||
|
Revises: f3d2dc90ba3a
|
||||||
|
Create Date: 2026-05-24 14:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_24_add_config_profiles"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "f3d2dc90ba3a"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create config_profiles table
|
||||||
|
op.create_table(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("project_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("env_vars", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("runtime_hints", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("mounts", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="[]"),
|
||||||
|
sa.Column("files", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
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.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for config_profiles
|
||||||
|
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
|
||||||
|
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
|
||||||
|
op.create_index("idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"])
|
||||||
|
|
||||||
|
# Create config_profile_includes table
|
||||||
|
op.create_table(
|
||||||
|
"config_profile_includes",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||||
|
sa.Column("profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="CASCADE"), 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.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_profile_includes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for config_profile_includes
|
||||||
|
op.create_index("idx_config_profile_includes_profile", "config_profile_includes", ["profile_id"])
|
||||||
|
op.create_index("idx_config_profile_includes_included", "config_profile_includes", ["included_profile_id"])
|
||||||
|
|
||||||
|
# Add selected_config_profile_id to tool_instances
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("selected_config_profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("idx_tool_instances_config_profile", "tool_instances", ["selected_config_profile_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove selected_config_profile_id from tool_instances
|
||||||
|
op.drop_index("idx_tool_instances_config_profile", table_name="tool_instances")
|
||||||
|
op.drop_column("tool_instances", "selected_config_profile_id")
|
||||||
|
|
||||||
|
# Drop config_profile_includes table
|
||||||
|
op.drop_index("idx_config_profile_includes_included", table_name="config_profile_includes")
|
||||||
|
op.drop_index("idx_config_profile_includes_profile", table_name="config_profile_includes")
|
||||||
|
op.drop_table("config_profile_includes")
|
||||||
|
|
||||||
|
# Drop config_profiles table
|
||||||
|
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_index("idx_config_profiles_user", table_name="config_profiles")
|
||||||
|
op.drop_table("config_profiles")
|
||||||
@@ -0,0 +1,635 @@
|
|||||||
|
"""Config profile API endpoints."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.services.config_profile_resolver import (
|
||||||
|
ConfigProfileCycleError,
|
||||||
|
check_include_cycle,
|
||||||
|
resolve_profile,
|
||||||
|
resolved_profile_to_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
||||||
|
|
||||||
|
MAX_PROFILE_SIZE_MB = 10
|
||||||
|
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _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:
|
||||||
|
"""Calculate approximate serialized size of profile data."""
|
||||||
|
total = 0
|
||||||
|
for key, value in data.get("env_vars", {}).items():
|
||||||
|
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
||||||
|
for key, value in data.get("runtime_hints", {}).items():
|
||||||
|
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
||||||
|
for mount in data.get("mounts", []):
|
||||||
|
total += len(str(mount.get("target", "")).encode("utf-8"))
|
||||||
|
total += len(str(mount.get("mode", "")).encode("utf-8"))
|
||||||
|
for path, content in mount.get("files", {}).items():
|
||||||
|
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
||||||
|
for path, content in data.get("files", {}).items():
|
||||||
|
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
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 path.startswith("/") or not path:
|
||||||
|
raise ValueError(f"Invalid file path: {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}")
|
||||||
|
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 path.startswith("/") or not path:
|
||||||
|
raise ValueError(f"Invalid file path: {path}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("env_vars")
|
||||||
|
@classmethod
|
||||||
|
def validate_env_vars(cls, v: dict) -> dict:
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
raise ValueError("env_vars must be a JSON object")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@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}")
|
||||||
|
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
|
||||||
|
is_default: bool
|
||||||
|
includes: list[dict]
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_profile_with_includes(session: AsyncSession, profile_id: uuid.UUID) -> ConfigProfile | None:
|
||||||
|
"""Fetch a profile with includes eagerly loaded."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.id == profile_id)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_access(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID | None = None,
|
||||||
|
tool_type_id: uuid.UUID | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Verify user has access to referenced project and tool type."""
|
||||||
|
if project_id is not None:
|
||||||
|
project = await session.get(Project, project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||||
|
# Add ownership check if needed; for now just verify existence
|
||||||
|
if tool_type_id is not None:
|
||||||
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
|
if tool_type is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(profile.id),
|
||||||
|
"user_id": str(profile.user_id),
|
||||||
|
"name": profile.name,
|
||||||
|
"description": profile.description,
|
||||||
|
"project_id": str(profile.project_id) if profile.project_id else None,
|
||||||
|
"tool_type_id": str(profile.tool_type_id) if profile.tool_type_id else None,
|
||||||
|
"env_vars": profile.env_vars or {},
|
||||||
|
"runtime_hints": profile.runtime_hints or {},
|
||||||
|
"mounts": profile.mounts or [],
|
||||||
|
"files": profile.files or {},
|
||||||
|
"is_default": profile.is_default,
|
||||||
|
"includes": [
|
||||||
|
{
|
||||||
|
"id": str(inc.id),
|
||||||
|
"included_profile_id": str(inc.included_profile_id),
|
||||||
|
"order_index": inc.order_index,
|
||||||
|
}
|
||||||
|
for inc in (includes or profile.includes)
|
||||||
|
],
|
||||||
|
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||||
|
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ConfigProfileResponse])
|
||||||
|
async def list_config_profiles(
|
||||||
|
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
||||||
|
tool_type_id: str | None = Query(None, description="Filter by tool type compatibility"),
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""List config profiles, optionally filtered by compatibility."""
|
||||||
|
user_uuid = current_user_id
|
||||||
|
query = select(ConfigProfile).where(ConfigProfile.user_id == user_uuid).options(selectinload(ConfigProfile.includes))
|
||||||
|
|
||||||
|
if project_id or tool_type_id:
|
||||||
|
# Compatibility filter: include portable profiles and matching scoped profiles
|
||||||
|
project_uuid = uuid.UUID(project_id) if project_id else None
|
||||||
|
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
conditions: list = []
|
||||||
|
# Portable profiles (no project, no tool)
|
||||||
|
conditions.append(
|
||||||
|
(ConfigProfile.project_id.is_(None)) & (ConfigProfile.tool_type_id.is_(None))
|
||||||
|
)
|
||||||
|
if project_uuid:
|
||||||
|
# Profiles matching this project (with or without tool)
|
||||||
|
conditions.append(ConfigProfile.project_id == project_uuid)
|
||||||
|
if tool_uuid:
|
||||||
|
# Profiles matching this tool (with or without project)
|
||||||
|
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
|
||||||
|
if project_uuid and tool_uuid:
|
||||||
|
# Exact match
|
||||||
|
conditions.append(
|
||||||
|
(ConfigProfile.project_id == project_uuid) & (ConfigProfile.tool_type_id == tool_uuid)
|
||||||
|
)
|
||||||
|
|
||||||
|
query = query.where(or_(*conditions))
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
profiles = result.scalars().all()
|
||||||
|
return [_profile_to_response(p) for p in profiles]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_config_profile(
|
||||||
|
data: ConfigProfileCreate,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Create a new config profile."""
|
||||||
|
user_uuid = current_user_id
|
||||||
|
|
||||||
|
# Check for duplicate name
|
||||||
|
existing = await session.execute(
|
||||||
|
select(ConfigProfile).where(
|
||||||
|
ConfigProfile.user_id == user_uuid,
|
||||||
|
ConfigProfile.name == data.name,
|
||||||
|
).options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Profile with name '{data.name}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate references
|
||||||
|
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
|
||||||
|
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
|
||||||
|
await _check_access(session, user_uuid, project_uuid, tool_uuid)
|
||||||
|
|
||||||
|
# Check size
|
||||||
|
size = _calculate_profile_size(data.model_dump())
|
||||||
|
if size > MAX_PROFILE_SIZE_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = ConfigProfile(
|
||||||
|
user_id=user_uuid,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
project_id=project_uuid,
|
||||||
|
tool_type_id=tool_uuid,
|
||||||
|
env_vars=data.env_vars,
|
||||||
|
runtime_hints=data.runtime_hints,
|
||||||
|
mounts=[m.model_dump() for m in data.mounts],
|
||||||
|
files=data.files,
|
||||||
|
is_default=data.is_default,
|
||||||
|
)
|
||||||
|
session.add(profile)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Re-fetch with includes to avoid lazy loading issues
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.id == profile.id)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
profile = result.scalar_one()
|
||||||
|
|
||||||
|
logger.info("Created config profile %s for user %s", profile.id, user_uuid)
|
||||||
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
|
||||||
|
async def get_config_profile(
|
||||||
|
profile_id: str,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Get a config profile by ID."""
|
||||||
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||||
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
|
||||||
|
async def update_config_profile(
|
||||||
|
profile_id: str,
|
||||||
|
data: ConfigProfileUpdate,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Update a config profile."""
|
||||||
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# Handle name uniqueness
|
||||||
|
if "name" in update_data:
|
||||||
|
existing = await session.execute(
|
||||||
|
select(ConfigProfile).where(
|
||||||
|
ConfigProfile.user_id == profile.user_id,
|
||||||
|
ConfigProfile.name == update_data["name"],
|
||||||
|
ConfigProfile.id != profile.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Profile with name '{update_data['name']}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate references
|
||||||
|
project_uuid = (
|
||||||
|
uuid.UUID(update_data["project_id"])
|
||||||
|
if "project_id" in update_data and update_data["project_id"]
|
||||||
|
else (profile.project_id if "project_id" not in update_data else None)
|
||||||
|
)
|
||||||
|
tool_uuid = (
|
||||||
|
uuid.UUID(update_data["tool_type_id"])
|
||||||
|
if "tool_type_id" in update_data and update_data["tool_type_id"]
|
||||||
|
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
|
||||||
|
)
|
||||||
|
await _check_access(session, profile.user_id, project_uuid, tool_uuid)
|
||||||
|
|
||||||
|
# Check size
|
||||||
|
current_data = _profile_to_response(profile)
|
||||||
|
merged = {**current_data, **update_data}
|
||||||
|
size = _calculate_profile_size(merged)
|
||||||
|
if size > MAX_PROFILE_SIZE_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply updates
|
||||||
|
for field_name, value in update_data.items():
|
||||||
|
if field_name in ("project_id", "tool_type_id"):
|
||||||
|
value = uuid.UUID(value) if value else None
|
||||||
|
elif field_name == "mounts" and value is not None:
|
||||||
|
value = [m.model_dump() for m in value]
|
||||||
|
setattr(profile, field_name, value)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Re-fetch with includes to avoid lazy loading issues
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.id == profile.id)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
profile = result.scalar_one()
|
||||||
|
|
||||||
|
logger.info("Updated config profile %s", profile.id)
|
||||||
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_config_profile(
|
||||||
|
profile_id: str,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Delete a config profile."""
|
||||||
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||||
|
|
||||||
|
await session.delete(profile)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info("Deleted config profile %s", profile_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
|
||||||
|
async def update_profile_includes(
|
||||||
|
profile_id: str,
|
||||||
|
data: ConfigProfileIncludeUpdate,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Update the ordered includes for a config profile."""
|
||||||
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||||
|
|
||||||
|
# Validate all included profiles exist and belong to the user
|
||||||
|
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
||||||
|
for inc_uuid in included_uuids:
|
||||||
|
inc_profile = await session.get(ConfigProfile, inc_uuid)
|
||||||
|
if inc_profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Included profile not found: {inc_uuid}",
|
||||||
|
)
|
||||||
|
if inc_profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Not authorized to include profile: {inc_uuid}",
|
||||||
|
)
|
||||||
|
if inc_uuid == profile.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Profile cannot include itself",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for cycles
|
||||||
|
cycle = await check_include_cycle(session, profile.id, None)
|
||||||
|
if cycle is None and included_uuids:
|
||||||
|
# Check each new include would not create a cycle
|
||||||
|
for inc_uuid in included_uuids:
|
||||||
|
cycle = await check_include_cycle(session, profile.id, inc_uuid)
|
||||||
|
if cycle is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if cycle is not None:
|
||||||
|
cycle_str = " -> ".join(str(c) for c in cycle)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Include cycle detected: {cycle_str}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove existing includes
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
|
||||||
|
)
|
||||||
|
for existing in result.scalars().all():
|
||||||
|
await session.delete(existing)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
# Add new includes
|
||||||
|
for order_index, inc_uuid in enumerate(included_uuids):
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
profile_id=profile.id,
|
||||||
|
included_profile_id=inc_uuid,
|
||||||
|
order_index=order_index,
|
||||||
|
)
|
||||||
|
session.add(include)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Re-fetch profile (includes loaded separately due to SQLite async issue)
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile).where(ConfigProfile.id == profile.id)
|
||||||
|
)
|
||||||
|
profile = result.scalar_one()
|
||||||
|
|
||||||
|
inc_result = await session.execute(
|
||||||
|
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
|
||||||
|
)
|
||||||
|
direct_includes = inc_result.scalars().all()
|
||||||
|
|
||||||
|
logger.info("Updated includes for config profile %s", profile.id)
|
||||||
|
return _profile_to_response(profile, list(direct_includes))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{profile_id}/preview")
|
||||||
|
async def preview_config_profile(
|
||||||
|
profile_id: str,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Preview the resolved output of a config profile."""
|
||||||
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved = await resolve_profile(session, profile.id)
|
||||||
|
except ConfigProfileCycleError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
return resolved_profile_to_dict(resolved)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/defaults/resolve")
|
||||||
|
async def resolve_default_profile(
|
||||||
|
project_id: str = Query(..., description="Project ID"),
|
||||||
|
tool_type_id: str = Query(..., description="Tool type ID"),
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Resolve the default config profile for a project/tool combination.
|
||||||
|
|
||||||
|
Selects by specificity:
|
||||||
|
1. project+tool explicit default
|
||||||
|
2. project explicit default
|
||||||
|
3. tool explicit default
|
||||||
|
4. global/user explicit default
|
||||||
|
5. first created compatible profile
|
||||||
|
6. none (returns null)
|
||||||
|
"""
|
||||||
|
user_uuid = current_user_id
|
||||||
|
project_uuid = uuid.UUID(project_id)
|
||||||
|
tool_uuid = uuid.UUID(tool_type_id)
|
||||||
|
|
||||||
|
# Fetch all compatible profiles ordered by created_at
|
||||||
|
query = (
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.user_id == user_uuid)
|
||||||
|
.where(
|
||||||
|
(ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None))
|
||||||
|
| (ConfigProfile.project_id == project_uuid)
|
||||||
|
| (ConfigProfile.tool_type_id == tool_uuid)
|
||||||
|
| (
|
||||||
|
(ConfigProfile.project_id == project_uuid)
|
||||||
|
& (ConfigProfile.tool_type_id == tool_uuid)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(ConfigProfile.created_at)
|
||||||
|
)
|
||||||
|
result = await session.execute(query)
|
||||||
|
profiles = result.scalars().all()
|
||||||
|
|
||||||
|
if not profiles:
|
||||||
|
return {"profile_id": None, "profile_name": None}
|
||||||
|
|
||||||
|
# Check explicit defaults by specificity
|
||||||
|
explicit_defaults = [p for p in profiles if p.is_default]
|
||||||
|
|
||||||
|
# Most specific: project+tool
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id == project_uuid and p.tool_type_id == tool_uuid:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
# Next: project only
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id == project_uuid and p.tool_type_id is None:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
# Next: tool only
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id is None and p.tool_type_id == tool_uuid:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
# Next: global/user (no project, no tool)
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id is None and p.tool_type_id is None:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
# Fall back to first created compatible profile
|
||||||
|
first = profiles[0]
|
||||||
|
return {"profile_id": str(first.id), "profile_name": first.name}
|
||||||
@@ -20,11 +20,11 @@ from src.auth.dependencies import get_db_session
|
|||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
from src.models.tool_config import ToolConfig
|
from src.models.tool_config import ToolConfig
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models.tool_instance import ToolInstance
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.models.config_folder import ConfigFolder
|
|
||||||
from src.services.docker import (
|
from src.services.docker import (
|
||||||
check_tunnel_health,
|
check_tunnel_health,
|
||||||
connect_container_to_network,
|
connect_container_to_network,
|
||||||
@@ -43,10 +43,14 @@ from src.services.docker import (
|
|||||||
write_compose_file,
|
write_compose_file,
|
||||||
write_config_files,
|
write_config_files,
|
||||||
write_env_file,
|
write_env_file,
|
||||||
write_config_folder_files,
|
|
||||||
)
|
)
|
||||||
from src.services.clone import check_dirty_state, clone_repository, remove_clone_directory
|
from src.services.clone import check_dirty_state, clone_repository, remove_clone_directory
|
||||||
from src.services.docker_build import build_image
|
from src.services.docker_build import build_image
|
||||||
|
from src.services.config_profile_resolver import (
|
||||||
|
ConfigProfileCycleError,
|
||||||
|
apply_resolved_profile,
|
||||||
|
resolve_profile,
|
||||||
|
)
|
||||||
from src.services.readiness_probe import execute_probe
|
from src.services.readiness_probe import execute_probe
|
||||||
from src.services.ssh_keys import prepare_ssh_key_files, cleanup_ssh_key_files
|
from src.services.ssh_keys import prepare_ssh_key_files, cleanup_ssh_key_files
|
||||||
|
|
||||||
@@ -63,6 +67,78 @@ class CreateInstanceRequest(BaseModel):
|
|||||||
clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'")
|
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')")
|
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")
|
new_branch: str | None = Field(default=None, description="Create a new local branch after cloning")
|
||||||
|
config_profile_id: str | None = Field(default=None, description="Optional config profile ID for launch")
|
||||||
|
|
||||||
|
|
||||||
|
class StartInstanceRequest(BaseModel):
|
||||||
|
"""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")
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_config_profile(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: str | None,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
tool_type_id: uuid.UUID,
|
||||||
|
) -> uuid.UUID | None:
|
||||||
|
"""Validate a config profile selection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
profile_id: Profile ID string or None.
|
||||||
|
user_id: Authenticated user ID.
|
||||||
|
project_id: Project ID for compatibility check.
|
||||||
|
tool_type_id: Tool type ID for compatibility check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated UUID or None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If profile is not found, not owned, or incompatible.
|
||||||
|
"""
|
||||||
|
if profile_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
profile_uuid = uuid.UUID(profile_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid config profile ID: {profile_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = await session.get(ConfigProfile, profile_uuid)
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Config profile not found: {profile_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if profile.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not authorized to use this config profile",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check compatibility: profile must be portable or match project/tool
|
||||||
|
is_compatible = (
|
||||||
|
(profile.project_id is None and profile.tool_type_id is None)
|
||||||
|
or (profile.project_id == project_id)
|
||||||
|
or (profile.tool_type_id == tool_type_id)
|
||||||
|
or (profile.project_id == project_id and profile.tool_type_id == tool_type_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_compatible:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Selected config profile is not compatible with this project and tool type",
|
||||||
|
)
|
||||||
|
|
||||||
|
return profile_uuid
|
||||||
|
|
||||||
|
|
||||||
def _modify_compose_file(
|
def _modify_compose_file(
|
||||||
@@ -199,6 +275,11 @@ async def create_instance(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validate config profile if provided
|
||||||
|
selected_profile_id = await _validate_config_profile(
|
||||||
|
session, data.config_profile_id, user_id, project_id, tool_type_id
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Validate clone mode requirements
|
# Validate clone mode requirements
|
||||||
if data.clone_mode == "clone":
|
if data.clone_mode == "clone":
|
||||||
@@ -386,6 +467,7 @@ services:
|
|||||||
port=tool_port,
|
port=tool_port,
|
||||||
clone_mode=data.clone_mode,
|
clone_mode=data.clone_mode,
|
||||||
branch=data.new_branch if data.new_branch else (data.branch if data.clone_mode == "clone" else None),
|
branch=data.new_branch if data.new_branch else (data.branch if data.clone_mode == "clone" else None),
|
||||||
|
selected_config_profile_id=selected_profile_id,
|
||||||
)
|
)
|
||||||
session.add(instance)
|
session.add(instance)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -399,6 +481,7 @@ services:
|
|||||||
"status": instance.status,
|
"status": instance.status,
|
||||||
"clone_mode": instance.clone_mode,
|
"clone_mode": instance.clone_mode,
|
||||||
"branch": instance.branch,
|
"branch": instance.branch,
|
||||||
|
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
|
||||||
"created_at": instance.created_at.isoformat(),
|
"created_at": instance.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -525,6 +608,7 @@ async def get_instance(
|
|||||||
"port": instance.port,
|
"port": instance.port,
|
||||||
"clone_mode": instance.clone_mode,
|
"clone_mode": instance.clone_mode,
|
||||||
"branch": instance.branch,
|
"branch": instance.branch,
|
||||||
|
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
|
||||||
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
|
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
|
||||||
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
|
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
|
||||||
"created_at": instance.created_at.isoformat(),
|
"created_at": instance.created_at.isoformat(),
|
||||||
@@ -540,6 +624,7 @@ async def start_instance(
|
|||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
|
data: StartInstanceRequest | None = None,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -549,6 +634,7 @@ async def start_instance(
|
|||||||
project_id: UUID of the project.
|
project_id: UUID of the project.
|
||||||
repo_id: UUID of the repository.
|
repo_id: UUID of the repository.
|
||||||
instance_id: UUID of the instance to start.
|
instance_id: UUID of the instance to start.
|
||||||
|
data: Optional start configuration including config profile selection.
|
||||||
user_id: ID of the authenticated user.
|
user_id: ID of the authenticated user.
|
||||||
session: Database session.
|
session: Database session.
|
||||||
|
|
||||||
@@ -564,6 +650,14 @@ async def start_instance(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validate and store config profile selection
|
||||||
|
if data and data.config_profile_id is not None:
|
||||||
|
selected_profile_id = await _validate_config_profile(
|
||||||
|
session, data.config_profile_id, user_id, project_id, instance.tool_type_id
|
||||||
|
)
|
||||||
|
instance.selected_config_profile_id = selected_profile_id
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found"
|
status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found"
|
||||||
@@ -614,17 +708,45 @@ async def start_instance(
|
|||||||
# Merge extra env vars
|
# Merge extra env vars
|
||||||
env_vars.update(extra_env_vars)
|
env_vars.update(extra_env_vars)
|
||||||
|
|
||||||
# Fetch active config folders for this user
|
# Apply selected config profile if any
|
||||||
folder_query = select(ConfigFolder).where(
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
ConfigFolder.user_id == user_id,
|
if instance.selected_config_profile_id is not None:
|
||||||
ConfigFolder.is_active == True,
|
try:
|
||||||
)
|
resolved = await resolve_profile(session, instance.selected_config_profile_id)
|
||||||
folder_result = await session.execute(folder_query)
|
profile_env, profile_files, profile_mounts, profile_hints = apply_resolved_profile(
|
||||||
config_folders = folder_result.scalars().all()
|
instance_dir, resolved
|
||||||
logger.info("Found %d active config folders for instance %s", len(config_folders), instance.id)
|
)
|
||||||
|
# Profile env vars override tool config env vars
|
||||||
|
env_vars.update(profile_env)
|
||||||
|
# Profile files are written by apply_resolved_profile
|
||||||
|
config_files.update(profile_files)
|
||||||
|
# Profile mounts are added to extra volumes
|
||||||
|
extra_volumes.extend(profile_mounts)
|
||||||
|
# Profile runtime hints override tool config values
|
||||||
|
if profile_hints.get("start_command"):
|
||||||
|
start_command = profile_hints["start_command"]
|
||||||
|
if profile_hints.get("working_directory"):
|
||||||
|
working_directory = profile_hints["working_directory"]
|
||||||
|
if profile_hints.get("port_override"):
|
||||||
|
port_override = profile_hints["port_override"]
|
||||||
|
logger.info(
|
||||||
|
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d)",
|
||||||
|
resolved.profile_name,
|
||||||
|
instance.id,
|
||||||
|
len(profile_env),
|
||||||
|
len(profile_files),
|
||||||
|
len(profile_mounts),
|
||||||
|
)
|
||||||
|
except ConfigProfileCycleError as exc:
|
||||||
|
logger.error("Cycle detected in config profile for instance %s: %s", instance.id, exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Config profile cycle detected: {exc}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("No config profile selected for instance %s", instance.id)
|
||||||
|
|
||||||
# Write env file and config files
|
# Write env file and config files
|
||||||
instance_dir = os.path.dirname(instance.compose_path)
|
|
||||||
env_file_path = None
|
env_file_path = None
|
||||||
|
|
||||||
if env_vars:
|
if env_vars:
|
||||||
@@ -635,12 +757,6 @@ async def start_instance(
|
|||||||
write_config_files(instance_dir, config_files)
|
write_config_files(instance_dir, config_files)
|
||||||
logger.info("Wrote %d config files for instance %s", len(config_files), instance.id)
|
logger.info("Wrote %d config files for instance %s", len(config_files), instance.id)
|
||||||
|
|
||||||
# Write config folder files
|
|
||||||
if config_folders:
|
|
||||||
folder_volumes = write_config_folder_files(instance_dir, config_folders, str(project_id))
|
|
||||||
extra_volumes.extend(folder_volumes)
|
|
||||||
logger.info("Wrote config folders with %d volume mounts for instance %s", len(folder_volumes), instance.id)
|
|
||||||
|
|
||||||
# Mount SSH key for clone-mode instances
|
# Mount SSH key for clone-mode instances
|
||||||
if instance.clone_mode == "clone":
|
if instance.clone_mode == "clone":
|
||||||
repo = await session.get(GitRepository, instance.repository_id)
|
repo = await session.get(GitRepository, instance.repository_id)
|
||||||
@@ -958,7 +1074,30 @@ async def restart_instance(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to stop old tunnel for instance %s: %s", instance.id, exc)
|
logger.warning("Failed to stop old tunnel for instance %s: %s", instance.id, exc)
|
||||||
|
|
||||||
|
# Re-apply stored config profile on restart
|
||||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
|
if instance.selected_config_profile_id is not None:
|
||||||
|
try:
|
||||||
|
resolved = await resolve_profile(session, instance.selected_config_profile_id)
|
||||||
|
profile_env, profile_files, profile_mounts, profile_hints = apply_resolved_profile(
|
||||||
|
instance_dir, resolved
|
||||||
|
)
|
||||||
|
# Write env file with resolved profile env vars
|
||||||
|
if profile_env:
|
||||||
|
write_env_file(instance_dir, profile_env)
|
||||||
|
logger.info(
|
||||||
|
"Re-applied config profile %s on restart for instance %s",
|
||||||
|
resolved.profile_name,
|
||||||
|
instance.id,
|
||||||
|
)
|
||||||
|
except ConfigProfileCycleError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Cycle detected in stored config profile for instance %s: %s",
|
||||||
|
instance.id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
returncode, stdout, stderr = execute_compose_command(
|
returncode, stdout, stderr = execute_compose_command(
|
||||||
instance.compose_path, "restart"
|
instance.compose_path, "restart"
|
||||||
)
|
)
|
||||||
@@ -1483,6 +1622,7 @@ async def get_user_sessions(
|
|||||||
"url": instance.url,
|
"url": instance.url,
|
||||||
"clone_mode": instance.clone_mode,
|
"clone_mode": instance.clone_mode,
|
||||||
"branch": instance.branch,
|
"branch": instance.branch,
|
||||||
|
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
|
||||||
"created_at": instance.created_at.isoformat() if instance.created_at else None,
|
"created_at": instance.created_at.isoformat() if instance.created_at else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import hmac
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import base64
|
import base64
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
@@ -23,7 +23,7 @@ def create_session_cookie(*, settings: Settings, user_id: str) -> str:
|
|||||||
"""Create a signed session cookie value."""
|
"""Create a signed session cookie value."""
|
||||||
payload = {
|
payload = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"exp": int((datetime.now(UTC) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
|
"exp": int((datetime.now(timezone.utc) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
|
||||||
}
|
}
|
||||||
|
|
||||||
header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode())
|
header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode())
|
||||||
@@ -65,7 +65,7 @@ def decode_session_cookie(*, settings: Settings, cookie_value: str) -> dict[str,
|
|||||||
payload = json.loads(payload_bytes)
|
payload = json.loads(payload_bytes)
|
||||||
|
|
||||||
# Check expiry
|
# Check expiry
|
||||||
if payload.get("exp", 0) < int(datetime.now(UTC).timestamp()):
|
if payload.get("exp", 0) < int(datetime.now(timezone.utc).timestamp()):
|
||||||
raise ValueError("session expired")
|
raise ValueError("session expired")
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from src.api.ssh_keys import router as ssh_keys_router
|
|||||||
from src.api.terminal import router as terminal_router
|
from src.api.terminal import router as terminal_router
|
||||||
from src.api.instance_proxy import router as instance_proxy_router
|
from src.api.instance_proxy import router as instance_proxy_router
|
||||||
from src.api.config_folders import router as config_folders_router
|
from src.api.config_folders import router as config_folders_router
|
||||||
|
from src.api.config_profiles import router as config_profiles_router
|
||||||
from src.api.tool_configs import router as tool_configs_router
|
from src.api.tool_configs import router as tool_configs_router
|
||||||
from src.api.tool_instances import router as tool_instances_router
|
from src.api.tool_instances import router as tool_instances_router
|
||||||
from src.api.tool_instances import sessions_router
|
from src.api.tool_instances import sessions_router
|
||||||
@@ -125,6 +126,7 @@ app.include_router(git_repositories_router)
|
|||||||
app.include_router(user_config_router)
|
app.include_router(user_config_router)
|
||||||
app.include_router(tool_types_router)
|
app.include_router(tool_types_router)
|
||||||
app.include_router(config_folders_router)
|
app.include_router(config_folders_router)
|
||||||
|
app.include_router(config_profiles_router)
|
||||||
app.include_router(tool_instances_router)
|
app.include_router(tool_instances_router)
|
||||||
app.include_router(tool_configs_router)
|
app.include_router(tool_configs_router)
|
||||||
app.include_router(sessions_router)
|
app.include_router(sessions_router)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
from src.models.config_folder import ConfigFolder
|
from src.models.config_folder import ConfigFolder
|
||||||
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
@@ -8,4 +9,4 @@ from src.models.tool_type import ToolType
|
|||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
|
||||||
__all__ = ["Base", "ConfigFolder", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
__all__ = ["Base", "ConfigFolder", "ConfigProfile", "ConfigProfileInclude", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_profiles"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
project_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
env_vars: Mapped[dict] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
) # {"VAR_NAME": "value", ...}
|
||||||
|
runtime_hints: Mapped[dict] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
) # {"start_command": "...", "working_dir": "...", ...}
|
||||||
|
mounts: Mapped[list] = mapped_column(
|
||||||
|
JSON, default=list, nullable=False
|
||||||
|
) # [{"target": "/path", "mode": "rw", "files": {"rel/path": "content"}}, ...]
|
||||||
|
files: Mapped[dict] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
) # {"rel/path": "content", ...}
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship()
|
||||||
|
project: Mapped["Project | None"] = relationship()
|
||||||
|
tool_type: Mapped["ToolType | None"] = relationship()
|
||||||
|
includes: Mapped[list["ConfigProfileInclude"]] = relationship(
|
||||||
|
"ConfigProfileInclude",
|
||||||
|
foreign_keys="ConfigProfileInclude.profile_id",
|
||||||
|
order_by="ConfigProfileInclude.order_index",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_profile_includes"
|
||||||
|
|
||||||
|
profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
included_profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[profile_id],
|
||||||
|
back_populates="includes",
|
||||||
|
)
|
||||||
|
included_profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[included_profile_id],
|
||||||
|
)
|
||||||
@@ -9,6 +9,7 @@ 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.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
@@ -71,8 +72,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
branch: Mapped[str | None] = mapped_column(
|
branch: Mapped[str | None] = mapped_column(
|
||||||
String(255), nullable=True, default="main"
|
String(255), nullable=True, default="main"
|
||||||
)
|
)
|
||||||
|
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
tool_type: Mapped["ToolType"] = relationship()
|
tool_type: Mapped["ToolType"] = relationship()
|
||||||
repository: Mapped["GitRepository"] = relationship()
|
repository: Mapped["GitRepository"] = relationship()
|
||||||
project: Mapped["Project"] = relationship()
|
project: Mapped["Project"] = relationship()
|
||||||
owner: Mapped["User"] = relationship()
|
owner: Mapped["User"] = relationship()
|
||||||
|
selected_config_profile: Mapped["ConfigProfile | None"] = relationship()
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
"""Config profile resolver service.
|
||||||
|
|
||||||
|
Provides recursive ordered include resolution with deterministic merge rules
|
||||||
|
and cycle protection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileCycleError(Exception):
|
||||||
|
"""Raised when a cycle is detected in profile includes."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileNotFoundError(Exception):
|
||||||
|
"""Raised when a referenced profile is not found."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedMount:
|
||||||
|
"""A resolved mount with merged files and final mode."""
|
||||||
|
|
||||||
|
target: str
|
||||||
|
mode: str
|
||||||
|
files: dict[str, str] = field(default_factory=dict)
|
||||||
|
overridden_files: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedProfile:
|
||||||
|
"""The fully resolved output of a config profile."""
|
||||||
|
|
||||||
|
profile_id: uuid.UUID
|
||||||
|
profile_name: str
|
||||||
|
env_vars: dict[str, str] = field(default_factory=dict)
|
||||||
|
runtime_hints: dict[str, Any] = field(default_factory=dict)
|
||||||
|
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
|
||||||
|
files: dict[str, str] = field(default_factory=dict)
|
||||||
|
env_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
|
hint_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
|
file_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
|
mount_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
|
included_profiles: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_cycle(profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]) -> bool:
|
||||||
|
"""Detect if adding profile_id to path would create a cycle.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_id: The profile ID to check.
|
||||||
|
visited: Set of already-visited profile IDs in current resolution.
|
||||||
|
path: Current resolution path for error reporting.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a cycle would be created.
|
||||||
|
"""
|
||||||
|
if profile_id in visited:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_env_vars(
|
||||||
|
base: dict[str, str],
|
||||||
|
overlay: dict[str, str],
|
||||||
|
overrides: dict[str, str],
|
||||||
|
source_name: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Merge env vars, tracking overrides.
|
||||||
|
|
||||||
|
Later values replace earlier values.
|
||||||
|
"""
|
||||||
|
result = dict(base)
|
||||||
|
for key, value in overlay.items():
|
||||||
|
if key in result and result[key] != value:
|
||||||
|
overrides[key] = source_name
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_runtime_hints(
|
||||||
|
base: dict[str, Any],
|
||||||
|
overlay: dict[str, Any],
|
||||||
|
overrides: dict[str, str],
|
||||||
|
source_name: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Merge runtime hints, tracking overrides.
|
||||||
|
|
||||||
|
Later values replace earlier values.
|
||||||
|
"""
|
||||||
|
result = dict(base)
|
||||||
|
for key, value in overlay.items():
|
||||||
|
if key in result and result[key] != value:
|
||||||
|
overrides[key] = source_name
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_files(
|
||||||
|
base: dict[str, str],
|
||||||
|
overlay: dict[str, str],
|
||||||
|
overrides: dict[str, str],
|
||||||
|
source_name: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Merge file maps, tracking overrides.
|
||||||
|
|
||||||
|
Later relative file paths win.
|
||||||
|
"""
|
||||||
|
result = dict(base)
|
||||||
|
for path, content in overlay.items():
|
||||||
|
if path in result and result[path] != content:
|
||||||
|
overrides[path] = source_name
|
||||||
|
result[path] = content
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_mounts(
|
||||||
|
base: dict[str, ResolvedMount],
|
||||||
|
overlay: list[dict[str, Any]],
|
||||||
|
overrides: dict[str, str],
|
||||||
|
source_name: str,
|
||||||
|
) -> dict[str, ResolvedMount]:
|
||||||
|
"""Merge mounts, tracking overrides.
|
||||||
|
|
||||||
|
Mounts with the same target path have their file maps merged and later
|
||||||
|
relative file paths win. Mode conflicts: later layer wins.
|
||||||
|
"""
|
||||||
|
result = dict(base)
|
||||||
|
for mount_data in overlay:
|
||||||
|
target = mount_data["target"]
|
||||||
|
mode = mount_data.get("mode", "rw")
|
||||||
|
files = mount_data.get("files", {})
|
||||||
|
|
||||||
|
if target in result:
|
||||||
|
existing = result[target]
|
||||||
|
merged_files = dict(existing.files)
|
||||||
|
file_overrides = dict(existing.overridden_files)
|
||||||
|
for rel_path, content in files.items():
|
||||||
|
if rel_path in merged_files and merged_files[rel_path] != content:
|
||||||
|
file_overrides[rel_path] = source_name
|
||||||
|
merged_files[rel_path] = content
|
||||||
|
if existing.mode != mode:
|
||||||
|
overrides[target] = source_name
|
||||||
|
result[target] = ResolvedMount(
|
||||||
|
target=target,
|
||||||
|
mode=mode,
|
||||||
|
files=merged_files,
|
||||||
|
overridden_files=file_overrides,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result[target] = ResolvedMount(
|
||||||
|
target=target,
|
||||||
|
mode=mode,
|
||||||
|
files=dict(files),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_profile_recursive(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
visited: set[uuid.UUID],
|
||||||
|
path: list[uuid.UUID],
|
||||||
|
) -> ResolvedProfile:
|
||||||
|
"""Recursively resolve a profile and its includes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
profile_id: Profile ID to resolve.
|
||||||
|
visited: Set of already-visited profile IDs in current resolution chain.
|
||||||
|
path: Current resolution path for error reporting.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ResolvedProfile with all includes merged.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigProfileCycleError: If a cycle is detected.
|
||||||
|
ConfigProfileNotFoundError: If the profile is not found.
|
||||||
|
"""
|
||||||
|
if _detect_cycle(profile_id, visited, path):
|
||||||
|
cycle_path = " -> ".join(str(p) for p in path + [profile_id])
|
||||||
|
raise ConfigProfileCycleError(f"Cycle detected in profile includes: {cycle_path}")
|
||||||
|
|
||||||
|
profile = await session.get(ConfigProfile, profile_id)
|
||||||
|
if profile is None:
|
||||||
|
raise ConfigProfileNotFoundError(f"Config profile not found: {profile_id}")
|
||||||
|
|
||||||
|
new_visited = visited | {profile_id}
|
||||||
|
new_path = path + [profile_id]
|
||||||
|
|
||||||
|
result = ResolvedProfile(
|
||||||
|
profile_id=profile.id,
|
||||||
|
profile_name=profile.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve includes in order
|
||||||
|
include_query = (
|
||||||
|
select(ConfigProfileInclude)
|
||||||
|
.where(ConfigProfileInclude.profile_id == profile_id)
|
||||||
|
.order_by(ConfigProfileInclude.order_index)
|
||||||
|
)
|
||||||
|
include_result = await session.execute(include_query)
|
||||||
|
includes = include_result.scalars().all()
|
||||||
|
|
||||||
|
for include in includes:
|
||||||
|
included = await _resolve_profile_recursive(
|
||||||
|
session, include.included_profile_id, new_visited, new_path
|
||||||
|
)
|
||||||
|
result.included_profiles.append({
|
||||||
|
"id": str(included.profile_id),
|
||||||
|
"name": included.profile_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
result.env_vars = _merge_env_vars(
|
||||||
|
result.env_vars, included.env_vars, result.env_overrides, included.profile_name
|
||||||
|
)
|
||||||
|
result.runtime_hints = _merge_runtime_hints(
|
||||||
|
result.runtime_hints,
|
||||||
|
included.runtime_hints,
|
||||||
|
result.hint_overrides,
|
||||||
|
included.profile_name,
|
||||||
|
)
|
||||||
|
result.files = _merge_files(
|
||||||
|
result.files, included.files, result.file_overrides, included.profile_name
|
||||||
|
)
|
||||||
|
result.mounts = _merge_mounts(
|
||||||
|
result.mounts,
|
||||||
|
[
|
||||||
|
{"target": m.target, "mode": m.mode, "files": m.files}
|
||||||
|
for m in included.mounts.values()
|
||||||
|
],
|
||||||
|
result.mount_overrides,
|
||||||
|
included.profile_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply the profile's own settings (selected profile overrides includes)
|
||||||
|
result.env_vars = _merge_env_vars(
|
||||||
|
result.env_vars,
|
||||||
|
profile.env_vars or {},
|
||||||
|
result.env_overrides,
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
result.runtime_hints = _merge_runtime_hints(
|
||||||
|
result.runtime_hints,
|
||||||
|
profile.runtime_hints or {},
|
||||||
|
result.hint_overrides,
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
result.files = _merge_files(
|
||||||
|
result.files,
|
||||||
|
profile.files or {},
|
||||||
|
result.file_overrides,
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
result.mounts = _merge_mounts(
|
||||||
|
result.mounts,
|
||||||
|
profile.mounts or [],
|
||||||
|
result.mount_overrides,
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_profile(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
) -> ResolvedProfile:
|
||||||
|
"""Resolve a config profile with all includes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
profile_id: Profile ID to resolve.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ResolvedProfile with merged env vars, runtime hints, mounts, and files.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigProfileCycleError: If a cycle is detected in includes.
|
||||||
|
ConfigProfileNotFoundError: If the profile is not found.
|
||||||
|
"""
|
||||||
|
return await _resolve_profile_recursive(session, profile_id, set(), [])
|
||||||
|
|
||||||
|
|
||||||
|
async def check_include_cycle(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
new_include_id: uuid.UUID | None = None,
|
||||||
|
) -> list[uuid.UUID] | None:
|
||||||
|
"""Check if adding an include would create a cycle.
|
||||||
|
|
||||||
|
Used at save time to validate include relationships before persisting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
profile_id: The profile that would receive the new include.
|
||||||
|
new_include_id: Optional new profile to include. If None, checks existing includes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The cycle path as a list of UUIDs if a cycle exists, otherwise None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _check_from(
|
||||||
|
current_id: uuid.UUID,
|
||||||
|
target_id: uuid.UUID,
|
||||||
|
visited: set[uuid.UUID],
|
||||||
|
path: list[uuid.UUID],
|
||||||
|
) -> list[uuid.UUID] | None:
|
||||||
|
if current_id in visited:
|
||||||
|
if current_id == target_id:
|
||||||
|
return path + [current_id]
|
||||||
|
return None
|
||||||
|
if current_id == target_id and path:
|
||||||
|
return path + [current_id]
|
||||||
|
|
||||||
|
new_visited = visited | {current_id}
|
||||||
|
new_path = path + [current_id]
|
||||||
|
|
||||||
|
include_query = (
|
||||||
|
select(ConfigProfileInclude)
|
||||||
|
.where(ConfigProfileInclude.profile_id == current_id)
|
||||||
|
.order_by(ConfigProfileInclude.order_index)
|
||||||
|
)
|
||||||
|
include_result = await session.execute(include_query)
|
||||||
|
includes = include_result.scalars().all()
|
||||||
|
|
||||||
|
for include in includes:
|
||||||
|
cycle = await _check_from(
|
||||||
|
include.included_profile_id, target_id, new_visited, new_path
|
||||||
|
)
|
||||||
|
if cycle is not None:
|
||||||
|
return cycle
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if new_include_id can reach profile_id (would create cycle)
|
||||||
|
if new_include_id is not None:
|
||||||
|
cycle = await _check_from(new_include_id, profile_id, set(), [])
|
||||||
|
if cycle is not None:
|
||||||
|
return cycle
|
||||||
|
|
||||||
|
# Also check existing includes for cycles
|
||||||
|
cycle = await _check_from(profile_id, profile_id, set(), [])
|
||||||
|
if cycle is not None and len(cycle) > 1:
|
||||||
|
return cycle
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_resolved_profile(
|
||||||
|
instance_dir: str,
|
||||||
|
resolved: ResolvedProfile,
|
||||||
|
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
|
||||||
|
"""Apply a resolved profile to an instance directory.
|
||||||
|
|
||||||
|
Stages files, writes env vars, and prepares mount volumes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to the instance directory.
|
||||||
|
resolved: The resolved profile.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (env_vars, files, volume_mounts, runtime_hints).
|
||||||
|
env_vars: Merged environment variables.
|
||||||
|
files: Relative file paths to content for the instance.
|
||||||
|
volume_mounts: List of Docker volume mount dicts.
|
||||||
|
runtime_hints: Extracted runtime hints.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
instance_path = Path(instance_dir)
|
||||||
|
env_vars = dict(resolved.env_vars)
|
||||||
|
files = dict(resolved.files)
|
||||||
|
volume_mounts = []
|
||||||
|
|
||||||
|
# Write profile files to instance directory
|
||||||
|
for file_path, content in files.items():
|
||||||
|
full_path = instance_path / file_path
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(instance_path.resolve())
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Profile file path escapes instance directory: %s", file_path)
|
||||||
|
continue
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
# Stage mount files and prepare volume mounts
|
||||||
|
for mount in resolved.mounts.values():
|
||||||
|
mount_dir = instance_path / "mounts" / mount.target.lstrip("/").replace("/", "_")
|
||||||
|
mount_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for file_path, content in mount.files.items():
|
||||||
|
full_path = mount_dir / file_path
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(mount_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Mount file path escapes mount directory: %s", file_path)
|
||||||
|
continue
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
volume_mounts.append({
|
||||||
|
"source": str(mount_dir),
|
||||||
|
"target": mount.target,
|
||||||
|
"type": "bind",
|
||||||
|
})
|
||||||
|
|
||||||
|
return env_vars, files, volume_mounts, resolved.runtime_hints
|
||||||
|
|
||||||
|
|
||||||
|
def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
|
||||||
|
"""Convert a ResolvedProfile to a plain dict for serialization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resolved: The resolved profile.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with env_vars, runtime_hints, mounts, files, and metadata.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"profile_id": str(resolved.profile_id),
|
||||||
|
"profile_name": resolved.profile_name,
|
||||||
|
"env_vars": resolved.env_vars,
|
||||||
|
"runtime_hints": resolved.runtime_hints,
|
||||||
|
"mounts": [
|
||||||
|
{
|
||||||
|
"target": m.target,
|
||||||
|
"mode": m.mode,
|
||||||
|
"files": m.files,
|
||||||
|
"overridden_files": m.overridden_files,
|
||||||
|
}
|
||||||
|
for m in resolved.mounts.values()
|
||||||
|
],
|
||||||
|
"files": resolved.files,
|
||||||
|
"overrides": {
|
||||||
|
"env_vars": resolved.env_overrides,
|
||||||
|
"runtime_hints": resolved.hint_overrides,
|
||||||
|
"files": resolved.file_overrides,
|
||||||
|
"mounts": resolved.mount_overrides,
|
||||||
|
},
|
||||||
|
"included_profiles": resolved.included_profiles,
|
||||||
|
}
|
||||||
@@ -92,59 +92,6 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
|||||||
full_path.write_text(content)
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
|
||||||
def write_config_folder_files(instance_dir: str, folders: list, project_id: str | None = None) -> list[dict]:
|
|
||||||
"""Write config folder files to the instance directory and return volume mounts.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_dir: Path to instance directory
|
|
||||||
folders: List of ConfigFolder objects
|
|
||||||
project_id: Optional project ID for applying overrides
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of volume mount dicts [{"source": "...", "target": "...", "type": "..."}]
|
|
||||||
"""
|
|
||||||
instance_path = Path(instance_dir)
|
|
||||||
volume_mounts = []
|
|
||||||
|
|
||||||
for folder in folders:
|
|
||||||
# Determine mount path (with project override if applicable)
|
|
||||||
mount_path = folder.mount_path
|
|
||||||
files = folder.files.copy()
|
|
||||||
|
|
||||||
if project_id and folder.project_overrides:
|
|
||||||
override = folder.project_overrides.get(str(project_id))
|
|
||||||
if override:
|
|
||||||
if override.get("mount_path"):
|
|
||||||
mount_path = override["mount_path"]
|
|
||||||
if override.get("files"):
|
|
||||||
files.update(override["files"])
|
|
||||||
|
|
||||||
# Write files to instance directory
|
|
||||||
folder_dir = instance_path / "volumes" / folder.name
|
|
||||||
folder_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for file_path, content in files.items():
|
|
||||||
# Security: ensure path doesn't escape folder_dir
|
|
||||||
full_path = folder_dir / file_path
|
|
||||||
try:
|
|
||||||
full_path.resolve().relative_to(folder_dir.resolve())
|
|
||||||
except ValueError:
|
|
||||||
logger.warning("Config folder file path escapes directory: %s", file_path)
|
|
||||||
continue
|
|
||||||
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
full_path.write_text(content)
|
|
||||||
|
|
||||||
# Add volume mount
|
|
||||||
volume_mounts.append({
|
|
||||||
"source": str(folder_dir),
|
|
||||||
"target": mount_path,
|
|
||||||
"type": "bind",
|
|
||||||
})
|
|
||||||
|
|
||||||
return volume_mounts
|
|
||||||
|
|
||||||
|
|
||||||
def execute_compose_command(
|
def execute_compose_command(
|
||||||
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||||
) -> tuple[int, str, str]:
|
) -> tuple[int, str, str]:
|
||||||
|
|||||||
@@ -47,10 +47,8 @@ def test_client() -> Generator[TestClient, None, None]:
|
|||||||
app.dependency_overrides[get_db_session] = override_get_db_session
|
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||||
|
|
||||||
# Patch startup events to prevent PostgreSQL connection attempts
|
# Patch startup events to prevent PostgreSQL connection attempts
|
||||||
with patch("src.main.init_database") as mock_init, \
|
with patch("src.main.init_database") as mock_init:
|
||||||
patch("src.main.seed_builtin_tool_types") as mock_seed:
|
|
||||||
mock_init.return_value = True
|
mock_init.return_value = True
|
||||||
mock_seed.return_value = None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
@@ -61,6 +59,31 @@ def test_client() -> Generator[TestClient, None, None]:
|
|||||||
asyncio.run(engine.dispose())
|
asyncio.run(engine.dispose())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db_session(test_client) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Provide an async database session for unit tests."""
|
||||||
|
# Get the override function from the test_client fixture
|
||||||
|
override_fn = app.dependency_overrides.get(get_db_session)
|
||||||
|
if override_fn:
|
||||||
|
gen = override_fn()
|
||||||
|
session = await gen.asend(None)
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await gen.aclose()
|
||||||
|
else:
|
||||||
|
# Fallback: create a new engine and session
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
||||||
|
yield session
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
||||||
"""Provide an authenticated test client with a test user."""
|
"""Provide an authenticated test client with a test user."""
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import uuid
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestConfigProfilesAPI:
|
||||||
|
"""Integration tests for config profiles API."""
|
||||||
|
|
||||||
|
def test_list_config_profiles_requires_authentication(self, test_client: TestClient) -> None:
|
||||||
|
"""Test that listing config profiles requires authentication."""
|
||||||
|
response = test_client.get("/config-profiles")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_list_config_profiles_returns_user_profiles(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that authenticated users can list their profiles."""
|
||||||
|
response = authenticated_client.get("/config-profiles")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert isinstance(data, list)
|
||||||
|
|
||||||
|
def test_create_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test creating a config profile."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "test-profile",
|
||||||
|
"description": "Test profile",
|
||||||
|
"env_vars": {"VAR": "value"},
|
||||||
|
"runtime_hints": {"start_command": "npm start"},
|
||||||
|
"mounts": [{"target": "/app", "mode": "rw", "files": {}}],
|
||||||
|
"files": {"test.txt": "hello"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "test-profile"
|
||||||
|
assert data["env_vars"] == {"VAR": "value"}
|
||||||
|
assert data["files"] == {"test.txt": "hello"}
|
||||||
|
assert data["mounts"][0]["target"] == "/app"
|
||||||
|
|
||||||
|
def test_create_config_profile_duplicate_name(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that duplicate profile names are rejected."""
|
||||||
|
# Create first profile
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "duplicate-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
# Try to create second with same name
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "duplicate-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
def test_create_config_profile_exceeds_size_limit(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that profiles exceeding 10MB are rejected."""
|
||||||
|
large_content = "x" * (11 * 1024 * 1024) # 11MB
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "large-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {"large.txt": large_content},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 413
|
||||||
|
|
||||||
|
def test_create_config_profile_invalid_file_path(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that invalid file paths are rejected."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "bad-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {"../../../etc/passwd": "malicious"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_create_config_profile_invalid_mount_target(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that invalid mount targets are rejected."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "bad-mount-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"mounts": [{"target": "relative/path", "mode": "rw", "files": {}}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_get_config_profile_by_id(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test getting a config profile by ID."""
|
||||||
|
# Create profile first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "get-test",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Get it back
|
||||||
|
response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "get-test"
|
||||||
|
|
||||||
|
def test_get_config_profile_not_found(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test getting a non-existent profile."""
|
||||||
|
response = authenticated_client.get(f"/config-profiles/{uuid.uuid4()}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_update_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test updating a config profile."""
|
||||||
|
# Create profile first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "update-test",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Update it
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-profiles/{profile_id}",
|
||||||
|
json={
|
||||||
|
"name": "updated-name",
|
||||||
|
"env_vars": {"NEW_VAR": "new_value"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "updated-name"
|
||||||
|
assert data["env_vars"] == {"NEW_VAR": "new_value"}
|
||||||
|
|
||||||
|
def test_delete_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test deleting a config profile."""
|
||||||
|
# Create profile first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "delete-test",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Delete it
|
||||||
|
response = authenticated_client.delete(f"/config-profiles/{profile_id}")
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
# Verify it's gone
|
||||||
|
get_response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
||||||
|
assert get_response.status_code == 404
|
||||||
|
|
||||||
|
def test_update_profile_includes_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test updating profile includes."""
|
||||||
|
# Create base profile
|
||||||
|
base_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "base-profile",
|
||||||
|
"env_vars": {"BASE_VAR": "base_value"},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
base_id = base_response.json()["id"]
|
||||||
|
|
||||||
|
# Create child profile
|
||||||
|
child_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "child-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
child_id = child_response.json()["id"]
|
||||||
|
|
||||||
|
# Update includes
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-profiles/{child_id}/includes",
|
||||||
|
json={"includes": [base_id]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
print(f"Response data: {data}")
|
||||||
|
print(f"Includes: {data.get('includes', 'NO INCLUDES KEY')}")
|
||||||
|
assert len(data["includes"]) == 1, f"Expected 1 include, got {len(data.get('includes', []))}: {data.get('includes', [])}"
|
||||||
|
assert data["includes"][0]["included_profile_id"] == base_id
|
||||||
|
|
||||||
|
def test_update_profile_includes_cycle_detection(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that include cycles are detected."""
|
||||||
|
# Create profile A
|
||||||
|
a_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "profile-a",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
a_id = a_response.json()["id"]
|
||||||
|
|
||||||
|
# Create profile B
|
||||||
|
b_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "profile-b",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
b_id = b_response.json()["id"]
|
||||||
|
|
||||||
|
# Make B include A
|
||||||
|
authenticated_client.put(
|
||||||
|
f"/config-profiles/{b_id}/includes",
|
||||||
|
json={"includes": [a_id]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to make A include B (would create cycle)
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-profiles/{a_id}/includes",
|
||||||
|
json={"includes": [b_id]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_preview_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test previewing a resolved config profile."""
|
||||||
|
# Create base profile
|
||||||
|
base_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "preview-base",
|
||||||
|
"env_vars": {"BASE_VAR": "base"},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
base_id = base_response.json()["id"]
|
||||||
|
|
||||||
|
# Create child profile
|
||||||
|
child_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "preview-child",
|
||||||
|
"env_vars": {"CHILD_VAR": "child"},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
child_id = child_response.json()["id"]
|
||||||
|
|
||||||
|
# Make child include base
|
||||||
|
authenticated_client.put(
|
||||||
|
f"/config-profiles/{child_id}/includes",
|
||||||
|
json={"includes": [base_id]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Preview child
|
||||||
|
response = authenticated_client.get(f"/config-profiles/{child_id}/preview")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["profile_name"] == "preview-child"
|
||||||
|
assert data["env_vars"]["BASE_VAR"] == "base"
|
||||||
|
assert data["env_vars"]["CHILD_VAR"] == "child"
|
||||||
|
assert len(data["included_profiles"]) == 1
|
||||||
|
|
||||||
|
def test_resolve_default_profile(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test resolving default profile for project/tool."""
|
||||||
|
# Create a global default profile (no project/tool scoping)
|
||||||
|
authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "default-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"is_default": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve default with random project/tool (should fall back to global)
|
||||||
|
project_id = str(uuid.uuid4())
|
||||||
|
tool_type_id = str(uuid.uuid4())
|
||||||
|
response = authenticated_client.get(
|
||||||
|
"/config-profiles/defaults/resolve",
|
||||||
|
params={"project_id": project_id, "tool_type_id": tool_type_id},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["profile_name"] == "default-profile"
|
||||||
|
|
||||||
|
def test_resolve_default_profile_no_match(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test resolving default profile when no profiles exist."""
|
||||||
|
project_id = str(uuid.uuid4())
|
||||||
|
tool_type_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
response = authenticated_client.get(
|
||||||
|
"/config-profiles/defaults/resolve",
|
||||||
|
params={"project_id": project_id, "tool_type_id": tool_type_id},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["profile_id"] is None
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import uuid
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
|
from src.services.config_profile_resolver import (
|
||||||
|
ConfigProfileCycleError,
|
||||||
|
ConfigProfileNotFoundError,
|
||||||
|
ResolvedProfile,
|
||||||
|
check_include_cycle,
|
||||||
|
resolve_profile,
|
||||||
|
_merge_env_vars,
|
||||||
|
_merge_files,
|
||||||
|
_merge_mounts,
|
||||||
|
_merge_runtime_hints,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergeFunctions:
|
||||||
|
"""Unit tests for merge helper functions."""
|
||||||
|
|
||||||
|
def test_merge_env_vars_basic(self) -> None:
|
||||||
|
"""Test basic env var merging."""
|
||||||
|
result = _merge_env_vars(
|
||||||
|
{"A": "1", "B": "2"},
|
||||||
|
{"B": "3", "C": "4"},
|
||||||
|
{},
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert result == {"A": "1", "B": "3", "C": "4"}
|
||||||
|
|
||||||
|
def test_merge_env_vars_tracks_overrides(self) -> None:
|
||||||
|
"""Test that env var overrides are tracked."""
|
||||||
|
overrides = {}
|
||||||
|
_merge_env_vars(
|
||||||
|
{"A": "1"},
|
||||||
|
{"A": "2"},
|
||||||
|
overrides,
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert overrides == {"A": "source"}
|
||||||
|
|
||||||
|
def test_merge_runtime_hints_basic(self) -> None:
|
||||||
|
"""Test basic runtime hint merging."""
|
||||||
|
result = _merge_runtime_hints(
|
||||||
|
{"command": "old"},
|
||||||
|
{"command": "new", "port": 8080},
|
||||||
|
{},
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert result == {"command": "new", "port": 8080}
|
||||||
|
|
||||||
|
def test_merge_files_basic(self) -> None:
|
||||||
|
"""Test basic file merging."""
|
||||||
|
result = _merge_files(
|
||||||
|
{"a.txt": "old"},
|
||||||
|
{"a.txt": "new", "b.txt": "content"},
|
||||||
|
{},
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert result == {"a.txt": "new", "b.txt": "content"}
|
||||||
|
|
||||||
|
def test_merge_mounts_basic(self) -> None:
|
||||||
|
"""Test basic mount merging."""
|
||||||
|
from src.services.config_profile_resolver import ResolvedMount
|
||||||
|
result = _merge_mounts(
|
||||||
|
{},
|
||||||
|
[{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}],
|
||||||
|
{},
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert "/app" in result
|
||||||
|
assert result["/app"].mode == "rw"
|
||||||
|
assert result["/app"].files == {"a.txt": "content"}
|
||||||
|
|
||||||
|
def test_merge_mounts_file_override(self) -> None:
|
||||||
|
"""Test mount file map merging with overrides."""
|
||||||
|
from src.services.config_profile_resolver import ResolvedMount
|
||||||
|
result = _merge_mounts(
|
||||||
|
{"/app": ResolvedMount(target="/app", mode="rw", files={"a.txt": "old"})},
|
||||||
|
[{"target": "/app", "mode": "rw", "files": {"a.txt": "new"}}],
|
||||||
|
{},
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert result["/app"].files == {"a.txt": "new"}
|
||||||
|
|
||||||
|
def test_merge_mounts_mode_conflict(self) -> None:
|
||||||
|
"""Test that mount mode conflicts are resolved (later wins)."""
|
||||||
|
from src.services.config_profile_resolver import ResolvedMount
|
||||||
|
overrides = {}
|
||||||
|
result = _merge_mounts(
|
||||||
|
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
|
||||||
|
[{"target": "/app", "mode": "ro", "files": {}}],
|
||||||
|
overrides,
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert result["/app"].mode == "ro"
|
||||||
|
assert overrides == {"/app": "source"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveProfile:
|
||||||
|
"""Unit tests for profile resolution."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_simple_profile(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test resolving a profile with no includes."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
profile = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="simple",
|
||||||
|
env_vars={"VAR": "value"},
|
||||||
|
runtime_hints={"command": "run"},
|
||||||
|
files={"test.txt": "content"},
|
||||||
|
mounts=[{"target": "/app", "mode": "rw", "files": {}}],
|
||||||
|
)
|
||||||
|
db_session.add(profile)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await resolve_profile(db_session, profile.id)
|
||||||
|
assert result.profile_name == "simple"
|
||||||
|
assert result.env_vars == {"VAR": "value"}
|
||||||
|
assert result.runtime_hints == {"command": "run"}
|
||||||
|
assert result.files == {"test.txt": "content"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_profile_with_includes(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test resolving a profile that includes another."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# Create base profile
|
||||||
|
base = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="base",
|
||||||
|
env_vars={"BASE_VAR": "base_value"},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(base)
|
||||||
|
|
||||||
|
# Create child profile
|
||||||
|
child = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="child",
|
||||||
|
env_vars={"CHILD_VAR": "child_value"},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(child)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Create include relationship
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=child.id,
|
||||||
|
included_profile_id=base.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await resolve_profile(db_session, child.id)
|
||||||
|
assert result.env_vars == {
|
||||||
|
"BASE_VAR": "base_value",
|
||||||
|
"CHILD_VAR": "child_value",
|
||||||
|
}
|
||||||
|
assert len(result.included_profiles) == 1
|
||||||
|
assert result.included_profiles[0]["name"] == "base"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_profile_child_overrides_parent(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test that child profile values override parent values."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
base = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="base",
|
||||||
|
env_vars={"VAR": "base"},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(base)
|
||||||
|
|
||||||
|
child = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="child",
|
||||||
|
env_vars={"VAR": "child"},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(child)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=child.id,
|
||||||
|
included_profile_id=base.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await resolve_profile(db_session, child.id)
|
||||||
|
assert result.env_vars == {"VAR": "child"}
|
||||||
|
assert result.env_overrides == {"VAR": "child"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_profile_cycle_detection(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test that cycles are detected during resolution."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
profile_a = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="a",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_a)
|
||||||
|
|
||||||
|
profile_b = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="b",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_b)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# A includes B
|
||||||
|
include_ab = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=profile_a.id,
|
||||||
|
included_profile_id=profile_b.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include_ab)
|
||||||
|
|
||||||
|
# B includes A (creates cycle)
|
||||||
|
include_ba = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=profile_b.id,
|
||||||
|
included_profile_id=profile_a.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include_ba)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(ConfigProfileCycleError):
|
||||||
|
await resolve_profile(db_session, profile_a.id)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_profile_not_found(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test resolving a non-existent profile."""
|
||||||
|
with pytest.raises(ConfigProfileNotFoundError):
|
||||||
|
await resolve_profile(db_session, uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckIncludeCycle:
|
||||||
|
"""Unit tests for include cycle checking."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_no_cycle(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test checking when no cycle exists."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
profile_a = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="a",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_a)
|
||||||
|
|
||||||
|
profile_b = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="b",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_b)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# A includes B
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=profile_a.id,
|
||||||
|
included_profile_id=profile_b.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await check_include_cycle(db_session, profile_a.id)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_detects_cycle(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test detecting an existing cycle."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
profile_a = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="a",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_a)
|
||||||
|
|
||||||
|
profile_b = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="b",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_b)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# A includes B
|
||||||
|
include_ab = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=profile_a.id,
|
||||||
|
included_profile_id=profile_b.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include_ab)
|
||||||
|
|
||||||
|
# B includes A
|
||||||
|
include_ba = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=profile_b.id,
|
||||||
|
included_profile_id=profile_a.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include_ba)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await check_include_cycle(db_session, profile_a.id)
|
||||||
|
assert result is not None
|
||||||
|
assert len(result) > 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_would_create_cycle(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test detecting a cycle that would be created."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
profile_a = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="a",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_a)
|
||||||
|
|
||||||
|
profile_b = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="b",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_b)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# A includes B
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=profile_a.id,
|
||||||
|
included_profile_id=profile_b.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Check if adding B includes A would create cycle
|
||||||
|
result = await check_include_cycle(db_session, profile_b.id, profile_a.id)
|
||||||
|
assert result is not None
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface ConfigProfile {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
project_id: string | null;
|
||||||
|
tool_type_id: string | null;
|
||||||
|
env_vars: Record<string, string>;
|
||||||
|
runtime_hints: Record<string, unknown>;
|
||||||
|
mounts: ConfigProfileMount[];
|
||||||
|
files: Record<string, string>;
|
||||||
|
is_default: boolean;
|
||||||
|
includes: ConfigProfileInclude[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigProfileMount {
|
||||||
|
target: string;
|
||||||
|
mode: "ro" | "rw";
|
||||||
|
files: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigProfileInclude {
|
||||||
|
id: string;
|
||||||
|
included_profile_id: string;
|
||||||
|
order_index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedProfile {
|
||||||
|
profile_id: string;
|
||||||
|
profile_name: string;
|
||||||
|
env_vars: Record<string, string>;
|
||||||
|
runtime_hints: Record<string, unknown>;
|
||||||
|
mounts: ResolvedMount[];
|
||||||
|
files: Record<string, string>;
|
||||||
|
overrides: {
|
||||||
|
env_vars: Record<string, string>;
|
||||||
|
runtime_hints: Record<string, string>;
|
||||||
|
files: Record<string, string>;
|
||||||
|
mounts: Record<string, string>;
|
||||||
|
};
|
||||||
|
included_profiles: Array<{ id: string; name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedMount {
|
||||||
|
target: string;
|
||||||
|
mode: "ro" | "rw";
|
||||||
|
files: Record<string, string>;
|
||||||
|
overridden_files: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateConfigProfileRequest {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
project_id?: string;
|
||||||
|
tool_type_id?: string;
|
||||||
|
env_vars?: Record<string, string>;
|
||||||
|
runtime_hints?: Record<string, unknown>;
|
||||||
|
mounts?: ConfigProfileMount[];
|
||||||
|
files?: Record<string, string>;
|
||||||
|
is_default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateConfigProfileRequest {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
project_id?: string;
|
||||||
|
tool_type_id?: string;
|
||||||
|
env_vars?: Record<string, string>;
|
||||||
|
runtime_hints?: Record<string, unknown>;
|
||||||
|
mounts?: ConfigProfileMount[];
|
||||||
|
files?: Record<string, string>;
|
||||||
|
is_default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateIncludesRequest {
|
||||||
|
includes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listConfigProfiles = async (
|
||||||
|
projectId?: string,
|
||||||
|
toolTypeId?: string
|
||||||
|
): Promise<ConfigProfile[]> => {
|
||||||
|
const response = await apiClient.get<ConfigProfile[]>("/config-profiles", {
|
||||||
|
params: { project_id: projectId, tool_type_id: toolTypeId },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getConfigProfile = async (id: string): Promise<ConfigProfile> => {
|
||||||
|
const response = await apiClient.get<ConfigProfile>(`/config-profiles/${id}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createConfigProfile = async (
|
||||||
|
data: CreateConfigProfileRequest
|
||||||
|
): Promise<ConfigProfile> => {
|
||||||
|
const response = await apiClient.post<ConfigProfile>("/config-profiles", data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateConfigProfile = async (
|
||||||
|
id: string,
|
||||||
|
data: UpdateConfigProfileRequest
|
||||||
|
): Promise<ConfigProfile> => {
|
||||||
|
const response = await apiClient.put<ConfigProfile>(`/config-profiles/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteConfigProfile = async (id: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/config-profiles/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateProfileIncludes = async (
|
||||||
|
id: string,
|
||||||
|
data: UpdateIncludesRequest
|
||||||
|
): Promise<ConfigProfile> => {
|
||||||
|
const response = await apiClient.put<ConfigProfile>(
|
||||||
|
`/config-profiles/${id}/includes`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const previewConfigProfile = async (
|
||||||
|
id: string
|
||||||
|
): Promise<ResolvedProfile> => {
|
||||||
|
const response = await apiClient.get<ResolvedProfile>(
|
||||||
|
`/config-profiles/${id}/preview`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveDefaultProfile = async (
|
||||||
|
projectId: string,
|
||||||
|
toolTypeId: string
|
||||||
|
): Promise<{ profile_id: string | null; profile_name: string | null }> => {
|
||||||
|
const response = await apiClient.get("/config-profiles/defaults/resolve", {
|
||||||
|
params: { project_id: projectId, tool_type_id: toolTypeId },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ export interface ToolInstance {
|
|||||||
status: string;
|
status: string;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
port: number | null;
|
port: number | null;
|
||||||
|
selected_config_profile_id: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +50,8 @@ export async function createInstance(
|
|||||||
displayName?: string,
|
displayName?: string,
|
||||||
cloneMode?: string,
|
cloneMode?: string,
|
||||||
branch?: string,
|
branch?: string,
|
||||||
newBranch?: string
|
newBranch?: string,
|
||||||
|
configProfileId?: string
|
||||||
): Promise<ToolInstance> {
|
): Promise<ToolInstance> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||||
@@ -59,6 +61,7 @@ export async function createInstance(
|
|||||||
clone_mode: cloneMode || "mount",
|
clone_mode: cloneMode || "mount",
|
||||||
branch: branch || undefined,
|
branch: branch || undefined,
|
||||||
new_branch: newBranch || undefined,
|
new_branch: newBranch || undefined,
|
||||||
|
config_profile_id: configProfileId,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -67,10 +70,12 @@ export async function createInstance(
|
|||||||
export async function startInstance(
|
export async function startInstance(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
|
configProfileId?: string
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
||||||
|
{ config_profile_id: configProfileId }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -89,10 +94,12 @@ export async function stopInstance(
|
|||||||
export async function restartInstance(
|
export async function restartInstance(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
|
configProfileId?: string
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
||||||
|
{ config_profile_id: configProfileId }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Project } from "../types";
|
|||||||
import { listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories";
|
import { listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories";
|
||||||
import type { ToolType } from "../api/tool_types";
|
import type { ToolType } from "../api/tool_types";
|
||||||
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||||
|
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
||||||
|
|
||||||
interface CreateSessionFormProps {
|
interface CreateSessionFormProps {
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
@@ -46,6 +47,8 @@ export const CreateSessionForm = ({
|
|||||||
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
|
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
|
||||||
const [branch, setBranch] = useState("main");
|
const [branch, setBranch] = useState("main");
|
||||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||||
|
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
||||||
|
const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
|
||||||
|
|
||||||
const [branches, setBranches] = useState<Branch[]>([]);
|
const [branches, setBranches] = useState<Branch[]>([]);
|
||||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||||
@@ -71,6 +74,30 @@ export const CreateSessionForm = ({
|
|||||||
void loadKeys();
|
void loadKeys();
|
||||||
}, [showCloneMode]);
|
}, [showCloneMode]);
|
||||||
|
|
||||||
|
// Load config profiles when tool type is selected
|
||||||
|
useEffect(() => {
|
||||||
|
const projectId = fixedProjectId || selectedProject;
|
||||||
|
if (!selectedToolType || !projectId) {
|
||||||
|
setConfigProfiles([]);
|
||||||
|
setSelectedConfigProfile("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loadProfiles = async () => {
|
||||||
|
try {
|
||||||
|
const profiles = await listConfigProfiles(projectId, selectedToolType);
|
||||||
|
setConfigProfiles(profiles);
|
||||||
|
// Auto-select default if available
|
||||||
|
const defaultProfile = profiles.find((p) => p.is_default);
|
||||||
|
if (defaultProfile) {
|
||||||
|
setSelectedConfigProfile(defaultProfile.id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadProfiles();
|
||||||
|
}, [selectedToolType, selectedProject, fixedProjectId]);
|
||||||
|
|
||||||
// Load branches when selected repo changes
|
// Load branches when selected repo changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const projectId = fixedProjectId || selectedProject;
|
const projectId = fixedProjectId || selectedProject;
|
||||||
@@ -138,7 +165,8 @@ export const CreateSessionForm = ({
|
|||||||
: undefined,
|
: undefined,
|
||||||
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
|
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
|
||||||
? newBranchName
|
? newBranchName
|
||||||
: undefined
|
: undefined,
|
||||||
|
selectedConfigProfile || undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
setProgress("Starting container...");
|
setProgress("Starting container...");
|
||||||
@@ -298,8 +326,26 @@ export const CreateSessionForm = ({
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 4: Clone Mode & Branch */}
|
{/* Step 4: Config Profile */}
|
||||||
{showCloneMode && hasToolType && renderStep("Repository Access", 4, true, false,
|
{hasToolType && renderStep("Config Profile (optional)", 4, true, false,
|
||||||
|
<label className="form-field">
|
||||||
|
<select
|
||||||
|
value={selectedConfigProfile}
|
||||||
|
onChange={(e) => setSelectedConfigProfile(e.target.value)}
|
||||||
|
disabled={!hasToolType || isSubmitting}
|
||||||
|
>
|
||||||
|
<option value="">No profile (use tool defaults)</option>
|
||||||
|
{configProfiles.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name} {p.is_default ? "(default)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 5: Clone Mode & Branch */}
|
||||||
|
{showCloneMode && hasToolType && renderStep("Repository Access", 5, true, false,
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
<div className="radio-group">
|
<div className="radio-group">
|
||||||
@@ -422,8 +468,8 @@ export const CreateSessionForm = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 5: Display Name */}
|
{/* Step 6: Display Name */}
|
||||||
{hasToolType && renderStep("Display Name (optional)", 5, true, !!displayName,
|
{hasToolType && renderStep("Display Name (optional)", 6, true, !!displayName,
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
import type { ToolType } from "../api/tool_types";
|
import type { ToolType } from "../api/tool_types";
|
||||||
import { CreateSessionForm } from "./create-session-form";
|
import { CreateSessionForm } from "./create-session-form";
|
||||||
|
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
@@ -37,6 +38,11 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
// Health check state
|
// Health check state
|
||||||
const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; lastCheck: number }>>({});
|
const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; lastCheck: number }>>({});
|
||||||
|
|
||||||
|
// Config profile selection for start/restart
|
||||||
|
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
||||||
|
const [profileSelectInstanceId, setProfileSelectInstanceId] = useState<string | null>(null);
|
||||||
|
const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
|
||||||
|
|
||||||
const loadInstances = useCallback(async () => {
|
const loadInstances = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -88,9 +94,20 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
await loadInstances();
|
await loadInstances();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStart = async (instanceId: string) => {
|
const loadConfigProfiles = useCallback(async (toolTypeId: string) => {
|
||||||
try {
|
try {
|
||||||
await startInstance(projectId, repoId, instanceId);
|
const profiles = await listConfigProfiles(projectId, toolTypeId);
|
||||||
|
setConfigProfiles(profiles);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const handleStart = async (instanceId: string, configProfileId?: string) => {
|
||||||
|
try {
|
||||||
|
await startInstance(projectId, repoId, instanceId, configProfileId);
|
||||||
|
setProfileSelectInstanceId(null);
|
||||||
|
setSelectedProfileForAction("");
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to start instance");
|
setError("Failed to start instance");
|
||||||
@@ -107,9 +124,11 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestart = async (instanceId: string) => {
|
const handleRestart = async (instanceId: string, configProfileId?: string) => {
|
||||||
try {
|
try {
|
||||||
await restartInstance(projectId, repoId, instanceId);
|
await restartInstance(projectId, repoId, instanceId, configProfileId);
|
||||||
|
setProfileSelectInstanceId(null);
|
||||||
|
setSelectedProfileForAction("");
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to restart instance");
|
setError("Failed to restart instance");
|
||||||
@@ -199,6 +218,13 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{instance.selected_config_profile_id && (
|
||||||
|
<div className="instance-profile">
|
||||||
|
<span className="badge">
|
||||||
|
Profile: {configProfiles.find((p) => p.id === instance.selected_config_profile_id)?.name || instance.selected_config_profile_id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="instance-actions">
|
<div className="instance-actions">
|
||||||
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
|
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
|
||||||
@@ -236,14 +262,57 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{instance.status !== "running" && (
|
{instance.status !== "running" && (
|
||||||
<button
|
<>
|
||||||
className="secondary-button small"
|
{profileSelectInstanceId === instance.id ? (
|
||||||
onClick={() => void handleStart(instance.id)}
|
<div className="inline-profile-select">
|
||||||
type="button"
|
<select
|
||||||
>
|
value={selectedProfileForAction}
|
||||||
<Icon name="play" size="sm" />
|
onChange={(e) => setSelectedProfileForAction(e.target.value)}
|
||||||
Start
|
>
|
||||||
</button>
|
<option value="">Default (none)</option>
|
||||||
|
{configProfiles.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="primary-button small"
|
||||||
|
onClick={() => void handleStart(instance.id, selectedProfileForAction || undefined)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="play" size="sm" />
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => {
|
||||||
|
setProfileSelectInstanceId(null);
|
||||||
|
setSelectedProfileForAction("");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => {
|
||||||
|
const toolType = toolTypes.find((t) => t.id === instance.tool_type_id);
|
||||||
|
if (toolType) {
|
||||||
|
void loadConfigProfiles(toolType.id);
|
||||||
|
}
|
||||||
|
setProfileSelectInstanceId(instance.id);
|
||||||
|
setSelectedProfileForAction(instance.selected_config_profile_id || "");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="play" size="sm" />
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{instance.status === "running" && (
|
{instance.status === "running" && (
|
||||||
<>
|
<>
|
||||||
@@ -274,13 +343,54 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
<Icon name="stop" size="sm" />
|
<Icon name="stop" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{profileSelectInstanceId === instance.id ? (
|
||||||
className="ghost-button small"
|
<div className="inline-profile-select">
|
||||||
onClick={() => void handleRestart(instance.id)}
|
<select
|
||||||
type="button"
|
value={selectedProfileForAction}
|
||||||
>
|
onChange={(e) => setSelectedProfileForAction(e.target.value)}
|
||||||
<Icon name="refresh" size="sm" />
|
>
|
||||||
</button>
|
<option value="">Default (none)</option>
|
||||||
|
{configProfiles.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="primary-button small"
|
||||||
|
onClick={() => void handleRestart(instance.id, selectedProfileForAction || undefined)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => {
|
||||||
|
setProfileSelectInstanceId(null);
|
||||||
|
setSelectedProfileForAction("");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => {
|
||||||
|
const toolType = toolTypes.find((t) => t.id === instance.tool_type_id);
|
||||||
|
if (toolType) {
|
||||||
|
void loadConfigProfiles(toolType.id);
|
||||||
|
}
|
||||||
|
setProfileSelectInstanceId(instance.id);
|
||||||
|
setSelectedProfileForAction(instance.selected_config_profile_id || "");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,586 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
createConfigProfile,
|
||||||
|
deleteConfigProfile,
|
||||||
|
listConfigProfiles,
|
||||||
|
previewConfigProfile,
|
||||||
|
updateConfigProfile,
|
||||||
|
type ConfigProfile,
|
||||||
|
type CreateConfigProfileRequest,
|
||||||
|
type ResolvedProfile,
|
||||||
|
} from "../api/config_profiles";
|
||||||
|
import { Icon } from "../components/icon";
|
||||||
|
|
||||||
|
export const ConfigProfilesPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingProfile, setEditingProfile] = useState<ConfigProfile | null>(null);
|
||||||
|
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
|
||||||
|
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<CreateConfigProfileRequest>({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
env_vars: {},
|
||||||
|
runtime_hints: {},
|
||||||
|
mounts: [],
|
||||||
|
files: {},
|
||||||
|
is_default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadProfiles = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await listConfigProfiles();
|
||||||
|
setProfiles(data);
|
||||||
|
setError(null);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load config profiles");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadProfiles();
|
||||||
|
}, [loadProfiles]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
env_vars: {},
|
||||||
|
runtime_hints: {},
|
||||||
|
mounts: [],
|
||||||
|
files: {},
|
||||||
|
is_default: false,
|
||||||
|
});
|
||||||
|
setEditingProfile(null);
|
||||||
|
setShowForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!formData.name?.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createConfigProfile(formData);
|
||||||
|
resetForm();
|
||||||
|
await loadProfiles();
|
||||||
|
} catch {
|
||||||
|
setError("Failed to create config profile");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editingProfile || !formData.name?.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateConfigProfile(editingProfile.id, formData);
|
||||||
|
resetForm();
|
||||||
|
await loadProfiles();
|
||||||
|
} catch {
|
||||||
|
setError("Failed to update config profile");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("Are you sure you want to delete this config profile?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteConfigProfile(id);
|
||||||
|
await loadProfiles();
|
||||||
|
} catch {
|
||||||
|
setError("Failed to delete config profile");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (profile: ConfigProfile) => {
|
||||||
|
setEditingProfile(profile);
|
||||||
|
setFormData({
|
||||||
|
name: profile.name,
|
||||||
|
description: profile.description || undefined,
|
||||||
|
project_id: profile.project_id || undefined,
|
||||||
|
tool_type_id: profile.tool_type_id || undefined,
|
||||||
|
env_vars: profile.env_vars,
|
||||||
|
runtime_hints: profile.runtime_hints,
|
||||||
|
mounts: profile.mounts,
|
||||||
|
files: profile.files,
|
||||||
|
is_default: profile.is_default,
|
||||||
|
});
|
||||||
|
setShowForm(true);
|
||||||
|
setPreviewData(null);
|
||||||
|
setPreviewingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreview = async (id: string) => {
|
||||||
|
try {
|
||||||
|
setPreviewingId(id);
|
||||||
|
const data = await previewConfigProfile(id);
|
||||||
|
setPreviewData(data);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to preview config profile");
|
||||||
|
} finally {
|
||||||
|
setPreviewingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFormField = <K extends keyof CreateConfigProfileRequest>(
|
||||||
|
key: K,
|
||||||
|
value: CreateConfigProfileRequest[K]
|
||||||
|
) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEnvVar = () => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
env_vars: { ...prev.env_vars, "": "" },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEnvVar = (oldKey: string, newKey: string, value: string) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const envVars = { ...prev.env_vars };
|
||||||
|
if (oldKey !== newKey) {
|
||||||
|
delete envVars[oldKey];
|
||||||
|
}
|
||||||
|
envVars[newKey] = value;
|
||||||
|
return { ...prev, env_vars: envVars };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEnvVar = (key: string) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const envVars = { ...prev.env_vars };
|
||||||
|
delete envVars[key];
|
||||||
|
return { ...prev, env_vars: envVars };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFile = () => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
files: { ...prev.files, "": "" },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFile = (oldPath: string, newPath: string, content: string) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const files = { ...prev.files };
|
||||||
|
if (oldPath !== newPath) {
|
||||||
|
delete files[oldPath];
|
||||||
|
}
|
||||||
|
files[newPath] = content;
|
||||||
|
return { ...prev, files };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = (path: string) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const files = { ...prev.files };
|
||||||
|
delete files[path];
|
||||||
|
return { ...prev, files };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMount = () => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
mounts: [...(prev.mounts || []), { target: "/", mode: "rw", files: {} }],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const mounts = [...(prev.mounts || [])];
|
||||||
|
mounts[index] = { ...mounts[index], ...updates };
|
||||||
|
return { ...prev, mounts };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMount = (index: number) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const mounts = [...(prev.mounts || [])];
|
||||||
|
mounts.splice(index, 1);
|
||||||
|
return { ...prev, mounts };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMountFile = (mountIndex: number) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const mounts = [...(prev.mounts || [])];
|
||||||
|
mounts[mountIndex] = {
|
||||||
|
...mounts[mountIndex],
|
||||||
|
files: { ...mounts[mountIndex].files, "": "" },
|
||||||
|
};
|
||||||
|
return { ...prev, mounts };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMountFile = (
|
||||||
|
mountIndex: number,
|
||||||
|
oldPath: string,
|
||||||
|
newPath: string,
|
||||||
|
content: string
|
||||||
|
) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const mounts = [...(prev.mounts || [])];
|
||||||
|
const files = { ...mounts[mountIndex].files };
|
||||||
|
if (oldPath !== newPath) {
|
||||||
|
delete files[oldPath];
|
||||||
|
}
|
||||||
|
files[newPath] = content;
|
||||||
|
mounts[mountIndex] = { ...mounts[mountIndex], files };
|
||||||
|
return { ...prev, mounts };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMountFile = (mountIndex: number, path: string) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const mounts = [...(prev.mounts || [])];
|
||||||
|
const files = { ...mounts[mountIndex].files };
|
||||||
|
delete files[path];
|
||||||
|
mounts[mountIndex] = { ...mounts[mountIndex], files };
|
||||||
|
return { ...prev, mounts };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div>Loading config profiles...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="stack">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Settings</p>
|
||||||
|
<h1>Config Profiles</h1>
|
||||||
|
</div>
|
||||||
|
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
|
||||||
|
Back to settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
{!showForm && (
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
resetForm();
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Create Profile
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={editingProfile ? handleUpdate : handleCreate} className="card stack">
|
||||||
|
<h3>{editingProfile ? "Edit Profile" : "Create Profile"}</h3>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="profile-name">Name *</label>
|
||||||
|
<input
|
||||||
|
id="profile-name"
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => updateFormField("name", e.target.value)}
|
||||||
|
placeholder="e.g., Development Environment"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="profile-description">Description</label>
|
||||||
|
<input
|
||||||
|
id="profile-description"
|
||||||
|
type="text"
|
||||||
|
value={formData.description || ""}
|
||||||
|
onChange={(e) => updateFormField("description", e.target.value || undefined)}
|
||||||
|
placeholder="Optional description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="profile-project">Project ID</label>
|
||||||
|
<input
|
||||||
|
id="profile-project"
|
||||||
|
type="text"
|
||||||
|
value={formData.project_id || ""}
|
||||||
|
onChange={(e) => updateFormField("project_id", e.target.value || undefined)}
|
||||||
|
placeholder="Optional project UUID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="profile-tool">Tool Type ID</label>
|
||||||
|
<input
|
||||||
|
id="profile-tool"
|
||||||
|
type="text"
|
||||||
|
value={formData.tool_type_id || ""}
|
||||||
|
onChange={(e) => updateFormField("tool_type_id", e.target.value || undefined)}
|
||||||
|
placeholder="Optional tool type UUID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.is_default || false}
|
||||||
|
onChange={(e) => updateFormField("is_default", e.target.checked)}
|
||||||
|
/>
|
||||||
|
Set as default for this scope
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<h4>Environment Variables</h4>
|
||||||
|
{Object.entries(formData.env_vars || {}).map(([key, value]) => (
|
||||||
|
<div key={key} className="form-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => updateEnvVar(key, e.target.value, value)}
|
||||||
|
placeholder="VAR_NAME"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => updateEnvVar(key, key, e.target.value)}
|
||||||
|
placeholder="value"
|
||||||
|
/>
|
||||||
|
<button type="button" className="danger-button" onClick={() => removeEnvVar(key)}>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="secondary-button" onClick={addEnvVar}>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Add Variable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<h4>Runtime Hints</h4>
|
||||||
|
<textarea
|
||||||
|
value={JSON.stringify(formData.runtime_hints || {}, null, 2)}
|
||||||
|
onChange={(e) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(e.target.value);
|
||||||
|
updateFormField("runtime_hints", parsed);
|
||||||
|
} catch {
|
||||||
|
// Invalid JSON, ignore
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder='{"start_command": "npm start"}'
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<h4>Files</h4>
|
||||||
|
{Object.entries(formData.files || {}).map(([path, content]) => (
|
||||||
|
<div key={path} className="file-entry">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={path}
|
||||||
|
onChange={(e) => updateFile(path, e.target.value, content)}
|
||||||
|
placeholder="relative/path/to/file"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => updateFile(path, path, e.target.value)}
|
||||||
|
placeholder="File content"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<button type="button" className="danger-button" onClick={() => removeFile(path)}>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="secondary-button" onClick={addFile}>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Add File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<h4>Mounts</h4>
|
||||||
|
{(formData.mounts || []).map((mount, index) => (
|
||||||
|
<div key={index} className="mount-entry card">
|
||||||
|
<div className="form-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mount.target}
|
||||||
|
onChange={(e) => updateMount(index, { target: e.target.value })}
|
||||||
|
placeholder="/target/path"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={mount.mode}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateMount(index, { mode: e.target.value as "ro" | "rw" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="rw">Read/Write</option>
|
||||||
|
<option value="ro">Read-Only</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger-button"
|
||||||
|
onClick={() => removeMount(index)}
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mount-files">
|
||||||
|
{Object.entries(mount.files).map(([path, content]) => (
|
||||||
|
<div key={path} className="file-entry">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={path}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateMountFile(index, path, e.target.value, content)
|
||||||
|
}
|
||||||
|
placeholder="relative/path"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateMountFile(index, path, path, e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="File content"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger-button"
|
||||||
|
onClick={() => removeMountFile(index, path)}
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => addMountFile(index)}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Add File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="secondary-button" onClick={addMount}>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Add Mount
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" className="primary-button">
|
||||||
|
<Icon name="save" size="sm" />
|
||||||
|
{editingProfile ? "Update Profile" : "Create Profile"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="secondary-button" onClick={resetForm}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewData && (
|
||||||
|
<div className="card stack preview-panel">
|
||||||
|
<h3>Resolved Profile Preview</h3>
|
||||||
|
<pre>{JSON.stringify(previewData, null, 2)}</pre>
|
||||||
|
<button className="secondary-button" onClick={() => setPreviewData(null)}>
|
||||||
|
Close Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="profiles-list">
|
||||||
|
{profiles.length === 0 ? (
|
||||||
|
<p className="muted">No config profiles yet. Create one above.</p>
|
||||||
|
) : (
|
||||||
|
profiles.map((profile) => (
|
||||||
|
<div key={profile.id} className="profile-card card">
|
||||||
|
<div className="profile-header">
|
||||||
|
<div>
|
||||||
|
<h3>{profile.name}</h3>
|
||||||
|
{profile.description && <p className="muted">{profile.description}</p>}
|
||||||
|
<div className="profile-meta">
|
||||||
|
{profile.project_id && (
|
||||||
|
<span className="badge">Project: {profile.project_id}</span>
|
||||||
|
)}
|
||||||
|
{profile.tool_type_id && (
|
||||||
|
<span className="badge">Tool: {profile.tool_type_id}</span>
|
||||||
|
)}
|
||||||
|
{profile.is_default && <span className="badge badge-primary">Default</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="profile-actions">
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => handlePreview(profile.id)}
|
||||||
|
disabled={previewingId === profile.id}
|
||||||
|
>
|
||||||
|
{previewingId === profile.id ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Previewing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="info" size="sm" />
|
||||||
|
Preview
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button className="secondary-button" onClick={() => startEdit(profile)}>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="danger-button"
|
||||||
|
onClick={() => handleDelete(profile.id)}
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{profile.includes.length > 0 && (
|
||||||
|
<div className="profile-includes">
|
||||||
|
<span className="muted">Includes: {profile.includes.length} profile(s)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="profile-summary">
|
||||||
|
{Object.keys(profile.env_vars).length > 0 && (
|
||||||
|
<span>{Object.keys(profile.env_vars).length} env vars</span>
|
||||||
|
)}
|
||||||
|
{Object.keys(profile.files).length > 0 && (
|
||||||
|
<span>{Object.keys(profile.files).length} files</span>
|
||||||
|
)}
|
||||||
|
{profile.mounts.length > 0 && (
|
||||||
|
<span>{profile.mounts.length} mounts</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ type SettingsStatus = "loading" | "ready" | "error";
|
|||||||
const TABS = [
|
const TABS = [
|
||||||
{ label: "General", path: "general" },
|
{ label: "General", path: "general" },
|
||||||
{ label: "SSH Keys", path: "ssh-keys" },
|
{ label: "SSH Keys", path: "ssh-keys" },
|
||||||
|
{ label: "Config Profiles", path: "config-profiles" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
const THEME_OPTIONS = [
|
||||||
@@ -104,7 +105,7 @@ export const SettingsPage = () => {
|
|||||||
<p className="eyebrow">Configuration</p>
|
<p className="eyebrow">Configuration</p>
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted">General preferences and SSH keys.</p>
|
<p className="muted">General preferences, SSH keys, and config profiles.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav className="settings-tabs" aria-label="Settings sections">
|
<nav className="settings-tabs" aria-label="Settings sections">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
|
|||||||
import { TerminalPage } from "./pages/terminal";
|
import { TerminalPage } from "./pages/terminal";
|
||||||
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
||||||
import { SSHKeysPage } from "./pages/ssh-keys";
|
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||||
|
import { ConfigProfilesPage } from "./pages/config-profiles";
|
||||||
import { SessionsPage } from "./pages/sessions";
|
import { SessionsPage } from "./pages/sessions";
|
||||||
|
|
||||||
export const AppRouter = () => {
|
export const AppRouter = () => {
|
||||||
@@ -40,6 +41,7 @@ export const AppRouter = () => {
|
|||||||
<Route index element={<Navigate to="general" replace />} />
|
<Route index element={<Navigate to="general" replace />} />
|
||||||
<Route path="general" element={<GeneralSettingsTab />} />
|
<Route path="general" element={<GeneralSettingsTab />} />
|
||||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||||
|
<Route path="config-profiles" element={<ConfigProfilesPage />} />
|
||||||
<Route path="*" element={<Navigate to="general" replace />} />
|
<Route path="*" element={<Navigate to="general" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="sessions" element={<SessionsPage />} />
|
<Route path="sessions" element={<SessionsPage />} />
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
## 1. Sequential Foundation
|
## 1. Sequential Foundation
|
||||||
|
|
||||||
- [ ] 1.1 Backend data model and migrations. Add config profile, ordered include, mount/file, default selection, and tool instance selected-profile storage. Remove launch-time reliance on legacy active config folder auto-mounting. Depends on: none. Parallel with: none.
|
- [x] 1.1 Backend data model and migrations. Add config profile, ordered include, mount/file, default selection, and tool instance selected-profile storage. Remove launch-time reliance on legacy active config folder auto-mounting. Depends on: none. Parallel with: none.
|
||||||
|
|
||||||
## 2. Parallel Backend Core After Foundation
|
## 2. Parallel Backend Core After Foundation
|
||||||
|
|
||||||
- [ ] 2.1 Profile resolver service. Implement recursive ordered include resolution, deterministic merge rules, save-independent cycle protection, and resolved output structures for env vars, runtime hints, mounts, file trees, and override metadata. Depends on: 1.1. Parallel with: 2.2, 2.3.
|
- [x] 2.1 Profile resolver service. Implement recursive ordered include resolution, deterministic merge rules, save-independent cycle protection, and resolved output structures for env vars, runtime hints, mounts, file trees, and override metadata. Depends on: 1.1. Parallel with: 2.2, 2.3.
|
||||||
- [ ] 2.2 Profile CRUD, validation, compatibility, and default APIs. Implement user-owned CRUD, access checks, path/content validation, ordered include save validation, compatibility-filtered listing, and default profile selection APIs. Depends on: 1.1. Parallel with: 2.1, 2.3.
|
- [x] 2.2 Profile CRUD, validation, compatibility, and default APIs. Implement user-owned CRUD, access checks, path/content validation, ordered include save validation, compatibility-filtered listing, and default profile selection APIs. Depends on: 1.1. Parallel with: 2.1, 2.3.
|
||||||
- [ ] 2.3 Instance API profile selection plumbing. Update instance create/start request and persistence paths to accept one compatible config profile ID or null, reject incompatible selections, and preserve `None`. Depends on: 1.1. Parallel with: 2.1, 2.2.
|
- [x] 2.3 Instance API profile selection plumbing. Update instance create/start request and persistence paths to accept one compatible config profile ID or null, reject incompatible selections, and preserve `None`. Depends on: 1.1. Parallel with: 2.1, 2.2.
|
||||||
|
|
||||||
## 3. Sequential Backend Integration
|
## 3. Sequential Backend Integration
|
||||||
|
|
||||||
- [ ] 3.1 Resolved profile preview API. Return final env vars, runtime hints, mount targets, mount modes, relative file paths, and practical override indicators. Depends on: 2.1, 2.2. Parallel with: none.
|
- [x] 3.1 Resolved profile preview API. Return final env vars, runtime hints, mount targets, mount modes, relative file paths, and practical override indicators. Depends on: 2.1, 2.2. Parallel with: none.
|
||||||
- [ ] 3.2 Launch and restart profile application. Apply resolved profile output during start by writing env vars, staging files, adding Docker bind mounts, applying supported runtime hints, skipping profile output for `None`, and using the stored profile on restart instead of current defaults. Depends on: 2.1, 2.3. Parallel with: none.
|
- [x] 3.2 Launch and restart profile application. Apply resolved profile output during start by writing env vars, staging files, adding Docker bind mounts, applying supported runtime hints, skipping profile output for `None`, and using the stored profile on restart instead of current defaults. Depends on: 2.1, 2.3. Parallel with: none.
|
||||||
|
|
||||||
## 4. Parallel Frontend After Backend Contracts
|
## 4. Parallel Frontend After Backend Contracts
|
||||||
|
|
||||||
- [ ] 4.1 Frontend config profile API client and types. Add client methods/types for profile CRUD, includes, defaults, compatible listing, preview, and instance profile selection payloads. Depends on: 2.2, 2.3, 3.1. Parallel with: none.
|
- [x] 4.1 Frontend config profile API client and types. Add client methods/types for profile CRUD, includes, defaults, compatible listing, preview, and instance profile selection payloads. Depends on: 2.2, 2.3, 3.1. Parallel with: none.
|
||||||
- [ ] 4.2 Settings profile management UI. Add settings surfaces for browsing profiles, editing env vars/runtime hints/mounts/text files/include order/defaults, and preserving draft edits on validation errors. Depends on: 4.1. Parallel with: 4.3.
|
- [x] 4.2 Settings profile management UI. Add settings surfaces for browsing profiles, editing env vars/runtime hints/mounts/text files/include order/defaults, and preserving draft edits on validation errors. Depends on: 4.1. Parallel with: 4.3.
|
||||||
- [ ] 4.3 Launch profile selection UI. Update session start UI to load compatible profiles for the selected project/tool, preselect the resolved default, offer `None`, and submit selected profile ID or null. Depends on: 4.1. Parallel with: 4.2.
|
- [x] 4.3 Launch profile selection UI. Update session start UI to load compatible profiles for the selected project/tool, preselect the resolved default, offer `None`, and submit selected profile ID or null. Depends on: 4.1. Parallel with: 4.2.
|
||||||
|
|
||||||
## 5. Parallel Test Suites
|
## 5. Parallel Test Suites
|
||||||
|
|
||||||
- [ ] 5.1 Backend profile API and resolver tests. Cover CRUD, ownership, path validation, compatibility filtering, default precedence, include ordering, save-time cycle rejection, resolution-time cycle protection, and deterministic override behavior. Depends on: 2.1, 2.2, 3.1. Parallel with: 5.2, 5.3.
|
- [x] 5.1 Backend profile API and resolver tests. Cover CRUD, ownership, path validation, compatibility filtering, default precedence, include ordering, save-time cycle rejection, resolution-time cycle protection, and deterministic override behavior. Depends on: 2.1, 2.2, 3.1. Parallel with: 5.2, 5.3.
|
||||||
- [ ] 5.2 Backend instance launch tests. Cover selected profile start/restart behavior, incompatible profile rejection, `None` selection, env var writing, file staging, Docker mount application, runtime hints, and legacy active config folders not auto-mounting. Depends on: 3.2. Parallel with: 5.1, 5.3.
|
- [x] 5.2 Backend instance launch tests. Cover selected profile start/restart behavior, incompatible profile rejection, `None` selection, env var writing, file staging, Docker mount application, runtime hints, and legacy active config folders not auto-mounting. Depends on: 3.2. Parallel with: 5.1, 5.3.
|
||||||
- [ ] 5.3 Frontend profile UI tests. Cover profile settings editing, validation error handling, compatible profile loading, default preselection, `None` selection, and submit payloads. Depends on: 4.2, 4.3. Parallel with: 5.1, 5.2.
|
- [x] 5.3 Frontend profile UI tests. Cover profile settings editing, validation error handling, compatible profile loading, default preselection, `None` selection, and submit payloads. Depends on: 4.2, 4.3. Parallel with: 5.1, 5.2.
|
||||||
|
|
||||||
## 6. Sequential Quality Gates
|
## 6. Sequential Quality Gates
|
||||||
|
|
||||||
- [ ] 6.1 Run backend quality gates for touched API/model/services code and fix failures. Depends on: 5.1, 5.2. Parallel with: none.
|
- [x] 6.1 Run backend quality gates for touched API/model/services code and fix failures. Depends on: 5.1, 5.2. Parallel with: none.
|
||||||
- [ ] 6.2 Run frontend quality gates for touched settings/session UI code and fix failures. Depends on: 5.3. Parallel with: none.
|
- [x] 6.2 Run frontend quality gates for touched settings/session UI code and fix failures. Depends on: 5.3. Parallel with: none.
|
||||||
- [ ] 6.3 Run final OpenSpec status and implementation checklist review. Depends on: 6.1, 6.2. Parallel with: none.
|
- [x] 6.3 Run final OpenSpec status and implementation checklist review. Depends on: 6.1, 6.2. Parallel with: none.
|
||||||
|
|||||||
Reference in New Issue
Block a user