Compare commits
9 Commits
dev
...
48fa858090
| Author | SHA1 | Date | |
|---|---|---|---|
| 48fa858090 | |||
| 679b1693fc | |||
| ea174b1642 | |||
| 9cc98455ef | |||
| a1dbfcf2a8 | |||
| 13aceeb08d | |||
| f0e19615ce | |||
| 0bea26c784 | |||
| fb0f2f7b9b |
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **User Settings** - Theme selection, git identity, and preference management
|
- **User Settings** - Theme selection, git identity, and preference management
|
||||||
- **SSH Key Management** - Ed25519 key generation with secure storage
|
- **SSH Key Management** - Ed25519 key generation with secure storage
|
||||||
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
|
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
|
||||||
|
- **Config Profiles** - User-owned profile CRUD with includes, mounts, path validation, cycle detection, and default profile selection
|
||||||
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
|
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""add config profiles, includes, mounts, and tool instance profile selection
|
||||||
|
|
||||||
|
Revision ID: 0013_add_config_profiles
|
||||||
|
Revises: 0012_default_port_req
|
||||||
|
Create Date: 2026-05-24 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0013_add_config_profiles"
|
||||||
|
down_revision: Union[str, None] = "0012_default_port_req"
|
||||||
|
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), nullable=False),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
|
||||||
|
|
||||||
|
# Create config_includes table
|
||||||
|
op.create_table(
|
||||||
|
"config_includes",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["included_profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"])
|
||||||
|
op.create_index("idx_config_includes_included", "config_includes", ["included_profile_id"])
|
||||||
|
|
||||||
|
# Create config_mounts table
|
||||||
|
op.create_table(
|
||||||
|
"config_mounts",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("mount_path", sa.String(length=1024), nullable=False),
|
||||||
|
sa.Column("content", sa.Text(), nullable=True),
|
||||||
|
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["source_profile_id"], ["config_profiles.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"])
|
||||||
|
|
||||||
|
# Add selected_profile_id to tool_instances
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_tool_instances_selected_profile",
|
||||||
|
"tool_instances",
|
||||||
|
"config_profiles",
|
||||||
|
["selected_profile_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
op.create_index("idx_tool_instances_selected_profile", "tool_instances", ["selected_profile_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove selected_profile_id from tool_instances
|
||||||
|
op.drop_index("idx_tool_instances_selected_profile", table_name="tool_instances")
|
||||||
|
op.drop_constraint("fk_tool_instances_selected_profile", "tool_instances", type_="foreignkey")
|
||||||
|
op.drop_column("tool_instances", "selected_profile_id")
|
||||||
|
|
||||||
|
# Drop config_mounts
|
||||||
|
op.drop_index("idx_config_mounts_profile", table_name="config_mounts")
|
||||||
|
op.drop_table("config_mounts")
|
||||||
|
|
||||||
|
# Drop config_includes
|
||||||
|
op.drop_index("idx_config_includes_included", table_name="config_includes")
|
||||||
|
op.drop_index("idx_config_includes_profile", table_name="config_includes")
|
||||||
|
op.drop_table("config_includes")
|
||||||
|
|
||||||
|
# Drop config_profiles
|
||||||
|
op.drop_index("idx_config_profiles_user", table_name="config_profiles")
|
||||||
|
op.drop_table("config_profiles")
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""add profile resolver fields to config profiles and mounts
|
||||||
|
|
||||||
|
Revision ID: 0014_add_profile_resolver_fields
|
||||||
|
Revises: 0013_add_config_profiles
|
||||||
|
Create Date: 2026-05-24 14:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0014_add_profile_resolver_fields"
|
||||||
|
down_revision: Union[str, None] = "0013_add_config_profiles"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add fields to config_profiles
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("environment_variables", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("start_command", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("working_directory", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("port", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add foreign keys for project and tool_type
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_config_profiles_project",
|
||||||
|
"config_profiles",
|
||||||
|
"projects",
|
||||||
|
["project_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_config_profiles_tool_type",
|
||||||
|
"config_profiles",
|
||||||
|
"tool_types",
|
||||||
|
["tool_type_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indices
|
||||||
|
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
|
||||||
|
op.create_index("idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"])
|
||||||
|
|
||||||
|
# Alter config_mounts: rename mount_path to target_path, add mode, change content to files JSON
|
||||||
|
op.alter_column("config_mounts", "mount_path", new_column_name="target_path")
|
||||||
|
op.add_column(
|
||||||
|
"config_mounts",
|
||||||
|
sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"config_mounts",
|
||||||
|
sa.Column("files", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
# Drop the source_profile foreign key if it exists
|
||||||
|
op.drop_constraint(
|
||||||
|
"config_mounts_source_profile_id_fkey",
|
||||||
|
"config_mounts",
|
||||||
|
type_="foreignkey",
|
||||||
|
)
|
||||||
|
op.drop_column("config_mounts", "content")
|
||||||
|
op.drop_column("config_mounts", "source_profile_id")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Restore config_mounts
|
||||||
|
op.add_column(
|
||||||
|
"config_mounts",
|
||||||
|
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"config_mounts",
|
||||||
|
sa.Column("content", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.drop_column("config_mounts", "files")
|
||||||
|
op.drop_column("config_mounts", "mode")
|
||||||
|
op.alter_column("config_mounts", "target_path", new_column_name="mount_path")
|
||||||
|
|
||||||
|
# Restore config_profiles
|
||||||
|
op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles")
|
||||||
|
op.drop_index("idx_config_profiles_project", table_name="config_profiles")
|
||||||
|
op.drop_constraint("fk_config_profiles_tool_type", "config_profiles", type_="foreignkey")
|
||||||
|
op.drop_constraint("fk_config_profiles_project", "config_profiles", type_="foreignkey")
|
||||||
|
op.drop_column("config_profiles", "is_default")
|
||||||
|
op.drop_column("config_profiles", "port")
|
||||||
|
op.drop_column("config_profiles", "working_directory")
|
||||||
|
op.drop_column("config_profiles", "start_command")
|
||||||
|
op.drop_column("config_profiles", "environment_variables")
|
||||||
|
op.drop_column("config_profiles", "tool_type_id")
|
||||||
|
op.drop_column("config_profiles", "project_id")
|
||||||
@@ -0,0 +1,877 @@
|
|||||||
|
"""Config profile API endpoints."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, 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_include import ConfigInclude
|
||||||
|
from src.models.config_mount import ConfigMount
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.models.user_config import UserConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
||||||
|
|
||||||
|
MAX_MOUNT_PATH_LENGTH = 1024
|
||||||
|
MAX_CONTENT_LENGTH = 1024 * 1024 # 1MB
|
||||||
|
MAX_INCLUDES_DEPTH = 10
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pydantic schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ConfigProfileCreate(BaseModel):
|
||||||
|
name: str = Field(description="Profile name (unique per user)")
|
||||||
|
description: str | None = Field(default=None, description="Optional description")
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def validate_name(cls, v: str) -> str:
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("Profile name cannot be empty")
|
||||||
|
if len(v) > 255:
|
||||||
|
raise ValueError("Profile name must be 255 characters or less")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, description="Profile name")
|
||||||
|
description: str | None = Field(default=None, description="Optional description")
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def validate_name(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("Profile name cannot be empty")
|
||||||
|
if len(v) > 255:
|
||||||
|
raise ValueError("Profile name must be 255 characters or less")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileDetailResponse(ConfigProfileResponse):
|
||||||
|
includes: list[dict[str, Any]]
|
||||||
|
mounts: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigIncludeCreate(BaseModel):
|
||||||
|
included_profile_id: str = Field(description="UUID of the profile to include")
|
||||||
|
order_index: int = Field(default=0, description="Order index for include resolution")
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigIncludeUpdate(BaseModel):
|
||||||
|
order_index: int = Field(description="Order index for include resolution")
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigIncludeResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
profile_id: str
|
||||||
|
included_profile_id: str
|
||||||
|
included_profile_name: str | None
|
||||||
|
order_index: int
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigMountCreate(BaseModel):
|
||||||
|
target_path: str = Field(description="Absolute target path in container")
|
||||||
|
mode: str = Field(default="rw", description="Mount mode (rw or ro)")
|
||||||
|
files: dict[str, str] | None = Field(default=None, description="Files as {path: content}")
|
||||||
|
order_index: int = Field(default=0, description="Order index for mount resolution")
|
||||||
|
|
||||||
|
@field_validator("target_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_target_path(cls, v: str) -> str:
|
||||||
|
if not v.startswith("/"):
|
||||||
|
raise ValueError("Target path must be absolute (start with /)")
|
||||||
|
if ".." in v:
|
||||||
|
raise ValueError("Target path cannot contain parent directory references (..)")
|
||||||
|
if len(v) > MAX_MOUNT_PATH_LENGTH:
|
||||||
|
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigMountUpdate(BaseModel):
|
||||||
|
target_path: str | None = Field(default=None, description="Absolute target path in container")
|
||||||
|
mode: str | None = Field(default=None, description="Mount mode (rw or ro)")
|
||||||
|
files: dict[str, str] | None = Field(default=None, description="Files as {path: content}")
|
||||||
|
order_index: int | None = Field(default=None, description="Order index for mount resolution")
|
||||||
|
|
||||||
|
@field_validator("target_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_target_path(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not v.startswith("/"):
|
||||||
|
raise ValueError("Target path must be absolute (start with /)")
|
||||||
|
if ".." in v:
|
||||||
|
raise ValueError("Target path cannot contain parent directory references (..)")
|
||||||
|
if len(v) > MAX_MOUNT_PATH_LENGTH:
|
||||||
|
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigMountResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
profile_id: str
|
||||||
|
target_path: str
|
||||||
|
mode: str
|
||||||
|
files: dict[str, str] | None
|
||||||
|
order_index: int
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultProfilesUpdate(BaseModel):
|
||||||
|
default_profiles: dict[str, str] = Field(description="Mapping of tool_type_id to profile_id")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _get_owned_profile(
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> ConfigProfile:
|
||||||
|
"""Fetch a config profile and verify ownership."""
|
||||||
|
profile = await session.get(ConfigProfile, profile_id)
|
||||||
|
if profile is None or profile.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="config profile not found",
|
||||||
|
)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
async def _detect_cycle(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
visited: set[uuid.UUID] | None = None,
|
||||||
|
depth: int = 0,
|
||||||
|
) -> bool:
|
||||||
|
"""Detect cycles in profile includes using DFS.
|
||||||
|
|
||||||
|
Returns True if a cycle is detected.
|
||||||
|
"""
|
||||||
|
if depth > MAX_INCLUDES_DEPTH:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if visited is None:
|
||||||
|
visited = set()
|
||||||
|
|
||||||
|
if profile_id in visited:
|
||||||
|
return True
|
||||||
|
|
||||||
|
visited.add(profile_id)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigInclude.included_profile_id).where(
|
||||||
|
ConfigInclude.profile_id == profile_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
included_ids = result.scalars().all()
|
||||||
|
|
||||||
|
for included_id in included_ids:
|
||||||
|
if await _detect_cycle(session, included_id, visited.copy(), depth + 1):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_includes_no_cycle(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
new_included_id: uuid.UUID | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Validate that adding an include wouldn't create a cycle."""
|
||||||
|
if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="adding this include would create a circular reference",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Profile CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
summary="List config profiles",
|
||||||
|
description="Get all config profiles for the current user. Optionally filter by tool type compatibility.",
|
||||||
|
)
|
||||||
|
async def list_config_profiles(
|
||||||
|
tool_type_id: str | None = None,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""List config profiles for the current user."""
|
||||||
|
query = select(ConfigProfile).where(ConfigProfile.user_id == user_id)
|
||||||
|
|
||||||
|
# If tool_type_id is provided, filter to compatible profiles
|
||||||
|
# For now, all profiles are considered compatible with all tool types
|
||||||
|
# since there's no explicit compatibility matrix. Future enhancement:
|
||||||
|
# could filter by profile tags or mount path patterns.
|
||||||
|
if tool_type_id:
|
||||||
|
# Validate the tool type exists
|
||||||
|
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
|
||||||
|
if tool_type is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="tool type not found",
|
||||||
|
)
|
||||||
|
# All profiles are compatible; just return user's profiles
|
||||||
|
pass
|
||||||
|
|
||||||
|
result = await session.execute(query.order_by(ConfigProfile.name))
|
||||||
|
profiles = result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"id": str(p.id),
|
||||||
|
"user_id": str(p.user_id),
|
||||||
|
"name": p.name,
|
||||||
|
"description": p.description,
|
||||||
|
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||||
|
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
|
||||||
|
}
|
||||||
|
for p in profiles
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
summary="Create config profile",
|
||||||
|
description="Create a new config profile.",
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_config_profile(
|
||||||
|
data: ConfigProfileCreate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Create a config profile."""
|
||||||
|
# Check for duplicate name
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(ConfigProfile).where(
|
||||||
|
ConfigProfile.user_id == user_id,
|
||||||
|
ConfigProfile.name == data.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"config profile with name '{data.name}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = ConfigProfile(
|
||||||
|
user_id=user_id,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
)
|
||||||
|
session.add(profile)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(profile)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(profile.id),
|
||||||
|
"user_id": str(profile.user_id),
|
||||||
|
"name": profile.name,
|
||||||
|
"description": profile.description,
|
||||||
|
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||||
|
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/defaults",
|
||||||
|
summary="Get default profiles",
|
||||||
|
description="Get the current user's default profile assignments per tool type.",
|
||||||
|
)
|
||||||
|
async def get_default_profiles(
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get default profiles for the current user."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||||
|
)
|
||||||
|
user_config = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user_config is None:
|
||||||
|
return {"default_profiles": {}}
|
||||||
|
|
||||||
|
return {"default_profiles": user_config.default_profiles}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/defaults",
|
||||||
|
summary="Set default profiles",
|
||||||
|
description="Set the current user's default profile assignments per tool type.",
|
||||||
|
)
|
||||||
|
async def set_default_profiles(
|
||||||
|
data: DefaultProfilesUpdate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Set default profiles for the current user."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||||
|
)
|
||||||
|
user_config = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user_config is None:
|
||||||
|
user_config = UserConfig(user_id=user_id, config={})
|
||||||
|
session.add(user_config)
|
||||||
|
|
||||||
|
# Validate all profile IDs belong to the user
|
||||||
|
for tool_type_id, profile_id_str in data.default_profiles.items():
|
||||||
|
profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"profile {profile_id_str} not found",
|
||||||
|
)
|
||||||
|
if profile.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"profile {profile_id_str} does not belong to user",
|
||||||
|
)
|
||||||
|
|
||||||
|
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
||||||
|
user_config.config = {**user_config.config, "default_profiles": data.default_profiles}
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user_config)
|
||||||
|
|
||||||
|
return {"default_profiles": user_config.default_profiles}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/defaults/{tool_type_id}",
|
||||||
|
summary="Get default profile for tool type",
|
||||||
|
description="Get the default profile ID for a specific tool type.",
|
||||||
|
)
|
||||||
|
async def get_default_profile_for_tool_type(
|
||||||
|
tool_type_id: str,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get default profile for a specific tool type."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||||
|
)
|
||||||
|
user_config = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user_config is None:
|
||||||
|
return {"tool_type_id": tool_type_id, "profile_id": None}
|
||||||
|
|
||||||
|
profile_id = user_config.default_profiles.get(tool_type_id)
|
||||||
|
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{profile_id}",
|
||||||
|
summary="Get config profile",
|
||||||
|
description="Get a config profile with its includes and mounts.",
|
||||||
|
)
|
||||||
|
async def get_config_profile(
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get a config profile with includes and mounts."""
|
||||||
|
profile = await session.get(
|
||||||
|
ConfigProfile,
|
||||||
|
profile_id,
|
||||||
|
options=[
|
||||||
|
selectinload(ConfigProfile.includes),
|
||||||
|
selectinload(ConfigProfile.mounts),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if profile is None or profile.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="config profile not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch included profile names
|
||||||
|
includes_data = []
|
||||||
|
for inc in profile.includes:
|
||||||
|
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
|
||||||
|
includes_data.append({
|
||||||
|
"id": str(inc.id),
|
||||||
|
"profile_id": str(inc.profile_id),
|
||||||
|
"included_profile_id": str(inc.included_profile_id),
|
||||||
|
"included_profile_name": included_profile.name if included_profile else None,
|
||||||
|
"order_index": inc.order_index,
|
||||||
|
"created_at": inc.created_at.isoformat() if inc.created_at else None,
|
||||||
|
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
mounts_data = [
|
||||||
|
{
|
||||||
|
"id": str(m.id),
|
||||||
|
"profile_id": str(m.profile_id),
|
||||||
|
"target_path": m.target_path,
|
||||||
|
"mode": m.mode,
|
||||||
|
"files": m.files,
|
||||||
|
"order_index": m.order_index,
|
||||||
|
"created_at": m.created_at.isoformat() if m.created_at else None,
|
||||||
|
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
|
||||||
|
}
|
||||||
|
for m in profile.mounts
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(profile.id),
|
||||||
|
"user_id": str(profile.user_id),
|
||||||
|
"name": profile.name,
|
||||||
|
"description": profile.description,
|
||||||
|
"includes": includes_data,
|
||||||
|
"mounts": mounts_data,
|
||||||
|
"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.put(
|
||||||
|
"/{profile_id}",
|
||||||
|
summary="Update config profile",
|
||||||
|
description="Update an existing config profile.",
|
||||||
|
)
|
||||||
|
async def update_config_profile(
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
data: ConfigProfileUpdate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Update a config profile."""
|
||||||
|
profile = await _get_owned_profile(profile_id, user_id, session)
|
||||||
|
|
||||||
|
if data.name is not None:
|
||||||
|
# Check for duplicate name
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(ConfigProfile).where(
|
||||||
|
ConfigProfile.user_id == user_id,
|
||||||
|
ConfigProfile.name == data.name,
|
||||||
|
ConfigProfile.id != profile_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"config profile with name '{data.name}' already exists",
|
||||||
|
)
|
||||||
|
profile.name = data.name
|
||||||
|
|
||||||
|
if data.description is not None:
|
||||||
|
profile.description = data.description
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(profile)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(profile.id),
|
||||||
|
"user_id": str(profile.user_id),
|
||||||
|
"name": profile.name,
|
||||||
|
"description": profile.description,
|
||||||
|
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||||
|
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{profile_id}",
|
||||||
|
summary="Delete config profile",
|
||||||
|
description="Delete a config profile and all its includes and mounts.",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
)
|
||||||
|
async def delete_config_profile(
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> None:
|
||||||
|
"""Delete a config profile."""
|
||||||
|
profile = await _get_owned_profile(profile_id, user_id, session)
|
||||||
|
await session.delete(profile)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Include management
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{profile_id}/includes",
|
||||||
|
summary="List profile includes",
|
||||||
|
description="Get all includes for a config profile.",
|
||||||
|
)
|
||||||
|
async def list_profile_includes(
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""List includes for a config profile."""
|
||||||
|
await _get_owned_profile(profile_id, user_id, session)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigInclude)
|
||||||
|
.where(ConfigInclude.profile_id == profile_id)
|
||||||
|
.order_by(ConfigInclude.order_index)
|
||||||
|
)
|
||||||
|
includes = result.scalars().all()
|
||||||
|
|
||||||
|
includes_data = []
|
||||||
|
for inc in includes:
|
||||||
|
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
|
||||||
|
includes_data.append({
|
||||||
|
"id": str(inc.id),
|
||||||
|
"profile_id": str(inc.profile_id),
|
||||||
|
"included_profile_id": str(inc.included_profile_id),
|
||||||
|
"included_profile_name": included_profile.name if included_profile else None,
|
||||||
|
"order_index": inc.order_index,
|
||||||
|
"created_at": inc.created_at.isoformat() if inc.created_at else None,
|
||||||
|
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"includes": includes_data}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{profile_id}/includes",
|
||||||
|
summary="Add profile include",
|
||||||
|
description="Add an include to a config profile.",
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def add_profile_include(
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
data: ConfigIncludeCreate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Add an include to a config profile."""
|
||||||
|
profile = await _get_owned_profile(profile_id, user_id, session)
|
||||||
|
|
||||||
|
included_profile_id = uuid.UUID(data.included_profile_id)
|
||||||
|
|
||||||
|
# Cannot include self
|
||||||
|
if included_profile_id == profile_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="a profile cannot include itself",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the included profile exists and belongs to the user
|
||||||
|
included_profile = await session.get(ConfigProfile, included_profile_id)
|
||||||
|
if included_profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="included profile not found",
|
||||||
|
)
|
||||||
|
if included_profile.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="included profile does not belong to user",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for duplicate include
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(ConfigInclude).where(
|
||||||
|
ConfigInclude.profile_id == profile_id,
|
||||||
|
ConfigInclude.included_profile_id == included_profile_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="this include already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate no cycles
|
||||||
|
await _validate_includes_no_cycle(session, profile_id, included_profile_id)
|
||||||
|
|
||||||
|
include = ConfigInclude(
|
||||||
|
profile_id=profile_id,
|
||||||
|
included_profile_id=included_profile_id,
|
||||||
|
order_index=data.order_index,
|
||||||
|
)
|
||||||
|
session.add(include)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(include)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(include.id),
|
||||||
|
"profile_id": str(include.profile_id),
|
||||||
|
"included_profile_id": str(include.included_profile_id),
|
||||||
|
"included_profile_name": included_profile.name,
|
||||||
|
"order_index": include.order_index,
|
||||||
|
"created_at": include.created_at.isoformat() if include.created_at else None,
|
||||||
|
"updated_at": include.updated_at.isoformat() if include.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{profile_id}/includes/{include_id}",
|
||||||
|
summary="Update profile include",
|
||||||
|
description="Update the order index of a profile include.",
|
||||||
|
)
|
||||||
|
async def update_profile_include(
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
include_id: uuid.UUID,
|
||||||
|
data: ConfigIncludeUpdate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Update a profile include."""
|
||||||
|
await _get_owned_profile(profile_id, user_id, session)
|
||||||
|
|
||||||
|
include = await session.get(ConfigInclude, include_id)
|
||||||
|
if include is None or include.profile_id != profile_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="include not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
include.order_index = data.order_index
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(include)
|
||||||
|
|
||||||
|
included_profile = await session.get(ConfigProfile, include.included_profile_id)
|
||||||
|
return {
|
||||||
|
"id": str(include.id),
|
||||||
|
"profile_id": str(include.profile_id),
|
||||||
|
"included_profile_id": str(include.included_profile_id),
|
||||||
|
"included_profile_name": included_profile.name if included_profile else None,
|
||||||
|
"order_index": include.order_index,
|
||||||
|
"created_at": include.created_at.isoformat() if include.created_at else None,
|
||||||
|
"updated_at": include.updated_at.isoformat() if include.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{profile_id}/includes/{include_id}",
|
||||||
|
summary="Remove profile include",
|
||||||
|
description="Remove an include from a config profile.",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
)
|
||||||
|
async def remove_profile_include(
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
include_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> None:
|
||||||
|
"""Remove an include from a config profile."""
|
||||||
|
await _get_owned_profile(profile_id, user_id, session)
|
||||||
|
|
||||||
|
include = await session.get(ConfigInclude, include_id)
|
||||||
|
if include is None or include.profile_id != profile_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="include not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.delete(include)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mount management
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{profile_id}/mounts",
|
||||||
|
summary="List profile mounts",
|
||||||
|
description="Get all mounts for a config profile.",
|
||||||
|
)
|
||||||
|
async def list_profile_mounts(
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""List mounts for a config profile."""
|
||||||
|
await _get_owned_profile(profile_id, user_id, session)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigMount)
|
||||||
|
.where(ConfigMount.profile_id == profile_id)
|
||||||
|
.order_by(ConfigMount.order_index)
|
||||||
|
)
|
||||||
|
mounts = result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mounts": [
|
||||||
|
{
|
||||||
|
"id": str(m.id),
|
||||||
|
"profile_id": str(m.profile_id),
|
||||||
|
"target_path": m.target_path,
|
||||||
|
"files": m.files,
|
||||||
|
"mode": m.mode,
|
||||||
|
"order_index": m.order_index,
|
||||||
|
"created_at": m.created_at.isoformat() if m.created_at else None,
|
||||||
|
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
|
||||||
|
}
|
||||||
|
for m in mounts
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{profile_id}/mounts",
|
||||||
|
summary="Add profile mount",
|
||||||
|
description="Add a mount to a config profile.",
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def add_profile_mount(
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
data: ConfigMountCreate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Add a mount to a config profile."""
|
||||||
|
profile = await _get_owned_profile(profile_id, user_id, session)
|
||||||
|
|
||||||
|
# Check for duplicate target_path
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(ConfigMount).where(
|
||||||
|
ConfigMount.profile_id == profile_id,
|
||||||
|
ConfigMount.target_path == data.target_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"mount with path '{data.target_path}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
mount = ConfigMount(
|
||||||
|
profile_id=profile_id,
|
||||||
|
target_path=data.target_path,
|
||||||
|
mode=data.mode,
|
||||||
|
files=data.files,
|
||||||
|
order_index=data.order_index,
|
||||||
|
)
|
||||||
|
session.add(mount)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(mount)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(mount.id),
|
||||||
|
"profile_id": str(mount.profile_id),
|
||||||
|
"target_path": mount.target_path,
|
||||||
|
"files": mount.files,
|
||||||
|
"mode": mount.mode,
|
||||||
|
"order_index": mount.order_index,
|
||||||
|
"created_at": mount.created_at.isoformat() if mount.created_at else None,
|
||||||
|
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{profile_id}/mounts/{mount_id}",
|
||||||
|
summary="Update profile mount",
|
||||||
|
description="Update a mount in a config profile.",
|
||||||
|
)
|
||||||
|
async def update_profile_mount(
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
mount_id: uuid.UUID,
|
||||||
|
data: ConfigMountUpdate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Update a profile mount."""
|
||||||
|
await _get_owned_profile(profile_id, user_id, session)
|
||||||
|
|
||||||
|
mount = await session.get(ConfigMount, mount_id)
|
||||||
|
if mount is None or mount.profile_id != profile_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="mount not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.target_path is not None:
|
||||||
|
# Check for duplicate target_path
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(ConfigMount).where(
|
||||||
|
ConfigMount.profile_id == profile_id,
|
||||||
|
ConfigMount.target_path == data.target_path,
|
||||||
|
ConfigMount.id != mount_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"mount with path '{data.target_path}' already exists",
|
||||||
|
)
|
||||||
|
mount.target_path = data.target_path
|
||||||
|
|
||||||
|
if data.files is not None:
|
||||||
|
mount.files = data.files
|
||||||
|
|
||||||
|
if data.order_index is not None:
|
||||||
|
mount.order_index = data.order_index
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(mount)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(mount.id),
|
||||||
|
"profile_id": str(mount.profile_id),
|
||||||
|
"target_path": mount.target_path,
|
||||||
|
"files": mount.files,
|
||||||
|
"mode": mount.mode,
|
||||||
|
"order_index": mount.order_index,
|
||||||
|
"created_at": mount.created_at.isoformat() if mount.created_at else None,
|
||||||
|
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{profile_id}/mounts/{mount_id}",
|
||||||
|
summary="Remove profile mount",
|
||||||
|
description="Remove a mount from a config profile.",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
)
|
||||||
|
async def remove_profile_mount(
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
mount_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> None:
|
||||||
|
"""Remove a mount from a config profile."""
|
||||||
|
await _get_owned_profile(profile_id, user_id, session)
|
||||||
|
|
||||||
|
mount = await session.get(ConfigMount, mount_id)
|
||||||
|
if mount is None or mount.profile_id != profile_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="mount not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.delete(mount)
|
||||||
|
await session.commit()
|
||||||
@@ -18,6 +18,7 @@ from src.auth.dependencies import get_current_user_id
|
|||||||
from src.auth.dependencies import get_db_session
|
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.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
|
||||||
@@ -41,6 +42,7 @@ from src.services.docker import (
|
|||||||
write_config_folder_files,
|
write_config_folder_files,
|
||||||
)
|
)
|
||||||
from src.services.docker_build import build_image
|
from src.services.docker_build import build_image
|
||||||
|
from src.services.profile_resolver import resolve_profile
|
||||||
from src.services.readiness_probe import execute_probe
|
from src.services.readiness_probe import execute_probe
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
||||||
@@ -53,6 +55,7 @@ class CreateInstanceRequest(BaseModel):
|
|||||||
|
|
||||||
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
|
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
|
||||||
display_name: str | None = Field(default=None, description="Optional display name for the instance")
|
display_name: str | None = Field(default=None, description="Optional display name for the instance")
|
||||||
|
config_profile_id: str | None = Field(default=None, description="Optional config profile ID to apply to the instance")
|
||||||
|
|
||||||
|
|
||||||
def _modify_compose_file(
|
def _modify_compose_file(
|
||||||
@@ -107,6 +110,68 @@ def _modify_compose_file(
|
|||||||
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_resolved_profile(
|
||||||
|
profile: ConfigProfile,
|
||||||
|
instance_dir: str,
|
||||||
|
env_vars: dict[str, str],
|
||||||
|
port_override: int | None,
|
||||||
|
start_command: str | None,
|
||||||
|
working_directory: str | None,
|
||||||
|
extra_volumes: list[dict],
|
||||||
|
) -> tuple[dict[str, str], int | None, str | None, str | None, list[dict]]:
|
||||||
|
"""Resolve a profile and apply its output to instance configuration.
|
||||||
|
|
||||||
|
Merges resolved profile env vars (profile wins), applies runtime hints,
|
||||||
|
stages mount files to the instance directory, and adds Docker bind mounts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: The config profile to resolve and apply.
|
||||||
|
instance_dir: Path to the instance directory.
|
||||||
|
env_vars: Current environment variables dict (will be updated).
|
||||||
|
port_override: Current port override (may be updated).
|
||||||
|
start_command: Current start command (may be updated).
|
||||||
|
working_directory: Current working directory (may be updated).
|
||||||
|
extra_volumes: Current extra volumes list (will be extended).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated (env_vars, port_override, start_command, working_directory, extra_volumes).
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
resolved = resolve_profile(profile)
|
||||||
|
|
||||||
|
# Merge env vars from resolved profile (profile wins over tool configs)
|
||||||
|
if resolved.environment_variables:
|
||||||
|
env_vars.update(resolved.environment_variables)
|
||||||
|
|
||||||
|
# Apply runtime hints
|
||||||
|
if resolved.runtime_hints.start_command is not None:
|
||||||
|
start_command = resolved.runtime_hints.start_command
|
||||||
|
if resolved.runtime_hints.working_directory is not None:
|
||||||
|
working_directory = resolved.runtime_hints.working_directory
|
||||||
|
if resolved.runtime_hints.port is not None:
|
||||||
|
port_override = resolved.runtime_hints.port
|
||||||
|
|
||||||
|
# Stage mount files and add volume mounts
|
||||||
|
for target_path, mount in resolved.mounts.items():
|
||||||
|
safe_name = target_path.strip("/").replace("/", "_")
|
||||||
|
mount_dir = Path(instance_dir) / "mounts" / safe_name
|
||||||
|
mount_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for rel_path, content in mount.files.items():
|
||||||
|
file_path = mount_dir / rel_path
|
||||||
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_path.write_text(content)
|
||||||
|
|
||||||
|
extra_volumes.append({
|
||||||
|
"source": str(mount_dir),
|
||||||
|
"target": target_path,
|
||||||
|
"type": mount.mode,
|
||||||
|
})
|
||||||
|
|
||||||
|
return env_vars, port_override, start_command, working_directory, extra_volumes
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
"""Fetch a user by ID or raise 404 if not found."""
|
"""Fetch a user by ID or raise 404 if not found."""
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
@@ -189,6 +254,29 @@ 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_id if provided
|
||||||
|
selected_profile_id: uuid.UUID | None = None
|
||||||
|
if data.config_profile_id:
|
||||||
|
try:
|
||||||
|
selected_profile_id = uuid.UUID(data.config_profile_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="invalid config_profile_id format",
|
||||||
|
)
|
||||||
|
|
||||||
|
config_profile = await session.get(ConfigProfile, selected_profile_id)
|
||||||
|
if config_profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="config profile not found",
|
||||||
|
)
|
||||||
|
if config_profile.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="config profile does not belong to user",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Generate unique name
|
# Generate unique name
|
||||||
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
|
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
|
||||||
@@ -262,6 +350,7 @@ services:
|
|||||||
status="pending",
|
status="pending",
|
||||||
compose_path=compose_path,
|
compose_path=compose_path,
|
||||||
port=tool_port,
|
port=tool_port,
|
||||||
|
selected_profile_id=selected_profile_id,
|
||||||
)
|
)
|
||||||
session.add(instance)
|
session.add(instance)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -273,6 +362,7 @@ services:
|
|||||||
"display_name": instance.display_name,
|
"display_name": instance.display_name,
|
||||||
"tool_type_id": str(instance.tool_type_id),
|
"tool_type_id": str(instance.tool_type_id),
|
||||||
"status": instance.status,
|
"status": instance.status,
|
||||||
|
"config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None,
|
||||||
"created_at": instance.created_at.isoformat(),
|
"created_at": instance.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -335,6 +425,7 @@ async def list_instances(
|
|||||||
"status": i.status,
|
"status": i.status,
|
||||||
"url": i.url,
|
"url": i.url,
|
||||||
"port": i.port,
|
"port": i.port,
|
||||||
|
"config_profile_id": str(i.selected_profile_id) if i.selected_profile_id else None,
|
||||||
"created_at": i.created_at.isoformat(),
|
"created_at": i.created_at.isoformat(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -395,6 +486,7 @@ async def get_instance(
|
|||||||
"compose_path": instance.compose_path,
|
"compose_path": instance.compose_path,
|
||||||
"url": instance.url,
|
"url": instance.url,
|
||||||
"port": instance.port,
|
"port": instance.port,
|
||||||
|
"config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None,
|
||||||
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
|
"last_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(),
|
||||||
@@ -452,6 +544,7 @@ async def start_instance(
|
|||||||
extra_env_vars = {}
|
extra_env_vars = {}
|
||||||
extra_volumes = []
|
extra_volumes = []
|
||||||
|
|
||||||
|
# Fetch all matching configs for this tool type
|
||||||
config_query = select(ToolConfig).where(
|
config_query = select(ToolConfig).where(
|
||||||
ToolConfig.user_id == user_id,
|
ToolConfig.user_id == user_id,
|
||||||
ToolConfig.tool_type_id == instance.tool_type_id,
|
ToolConfig.tool_type_id == instance.tool_type_id,
|
||||||
@@ -484,6 +577,31 @@ async def start_instance(
|
|||||||
# Merge extra env vars
|
# Merge extra env vars
|
||||||
env_vars.update(extra_env_vars)
|
env_vars.update(extra_env_vars)
|
||||||
|
|
||||||
|
# Apply resolved profile output if a profile is selected
|
||||||
|
if instance.selected_profile_id:
|
||||||
|
selected_profile = await session.get(ConfigProfile, instance.selected_profile_id)
|
||||||
|
if selected_profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="config profile not found",
|
||||||
|
)
|
||||||
|
if selected_profile.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="config profile does not belong to user",
|
||||||
|
)
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
|
env_vars, port_override, start_command, working_directory, extra_volumes = await _apply_resolved_profile(
|
||||||
|
selected_profile,
|
||||||
|
instance_dir,
|
||||||
|
env_vars,
|
||||||
|
port_override,
|
||||||
|
start_command,
|
||||||
|
working_directory,
|
||||||
|
extra_volumes,
|
||||||
|
)
|
||||||
|
logger.info("Applied resolved profile %s for instance %s", selected_profile.name, instance.id)
|
||||||
|
|
||||||
# Fetch active config folders for this user
|
# Fetch active config folders for this user
|
||||||
folder_query = select(ConfigFolder).where(
|
folder_query = select(ConfigFolder).where(
|
||||||
ConfigFolder.user_id == user_id,
|
ConfigFolder.user_id == user_id,
|
||||||
@@ -755,8 +873,105 @@ async def restart_instance(
|
|||||||
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)
|
||||||
|
|
||||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
|
# Re-apply configuration using stored profile instead of current defaults
|
||||||
|
env_vars = {}
|
||||||
|
config_files = {}
|
||||||
|
port_override = None
|
||||||
|
start_command = None
|
||||||
|
working_directory = None
|
||||||
|
extra_env_vars = {}
|
||||||
|
extra_volumes = []
|
||||||
|
|
||||||
|
# Fetch all matching configs for this tool type
|
||||||
|
config_query = select(ToolConfig).where(
|
||||||
|
ToolConfig.user_id == user_id,
|
||||||
|
ToolConfig.tool_type_id == instance.tool_type_id,
|
||||||
|
).where(
|
||||||
|
(ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None))
|
||||||
|
)
|
||||||
|
|
||||||
|
config_result = await session.execute(config_query)
|
||||||
|
configs = config_result.scalars().all()
|
||||||
|
logger.info("Found %d tool configs for restart of instance %s", len(configs), instance.id)
|
||||||
|
|
||||||
|
for config in configs:
|
||||||
|
if config.config_type == "env":
|
||||||
|
env_vars[config.key] = config.value
|
||||||
|
elif config.config_type == "file" and config.file_path:
|
||||||
|
config_files[config.file_path] = config.value
|
||||||
|
|
||||||
|
if config.port_override:
|
||||||
|
port_override = config.port_override
|
||||||
|
if config.start_command:
|
||||||
|
start_command = config.start_command
|
||||||
|
if config.working_directory:
|
||||||
|
working_directory = config.working_directory
|
||||||
|
if config.environment_variables:
|
||||||
|
extra_env_vars.update(config.environment_variables)
|
||||||
|
if config.volumes:
|
||||||
|
extra_volumes.extend(config.volumes)
|
||||||
|
|
||||||
|
# Merge extra env vars
|
||||||
|
env_vars.update(extra_env_vars)
|
||||||
|
|
||||||
|
# Apply stored profile on restart instead of current defaults
|
||||||
|
if instance.selected_profile_id:
|
||||||
|
stored_profile = await session.get(ConfigProfile, instance.selected_profile_id)
|
||||||
|
if stored_profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="config profile not found",
|
||||||
|
)
|
||||||
|
if stored_profile.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="config profile does not belong to user",
|
||||||
|
)
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
|
env_vars, port_override, start_command, working_directory, extra_volumes = await _apply_resolved_profile(
|
||||||
|
stored_profile,
|
||||||
|
instance_dir,
|
||||||
|
env_vars,
|
||||||
|
port_override,
|
||||||
|
start_command,
|
||||||
|
working_directory,
|
||||||
|
extra_volumes,
|
||||||
|
)
|
||||||
|
logger.info("Re-applied stored profile %s for restart of instance %s", stored_profile.name, instance.id)
|
||||||
|
|
||||||
|
# Fetch active config folders for this user
|
||||||
|
folder_query = select(ConfigFolder).where(
|
||||||
|
ConfigFolder.user_id == user_id,
|
||||||
|
ConfigFolder.is_active == True,
|
||||||
|
)
|
||||||
|
folder_result = await session.execute(folder_query)
|
||||||
|
config_folders = folder_result.scalars().all()
|
||||||
|
|
||||||
|
# Write env file and config files
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
|
env_file_path = None
|
||||||
|
|
||||||
|
if env_vars:
|
||||||
|
env_file_path = write_env_file(instance_dir, env_vars)
|
||||||
|
logger.info("Wrote env file for restart of instance %s: %s", instance.id, env_file_path)
|
||||||
|
|
||||||
|
if config_files:
|
||||||
|
write_config_files(instance_dir, config_files)
|
||||||
|
logger.info("Wrote %d config files for restart of 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 restart of instance %s", len(folder_volumes), instance.id)
|
||||||
|
|
||||||
|
# Modify compose file if needed
|
||||||
|
if port_override or start_command or working_directory or extra_volumes:
|
||||||
|
_modify_compose_file(instance.compose_path, port_override, start_command, working_directory, extra_volumes)
|
||||||
|
logger.info("Modified compose file for restart of instance %s", instance.id)
|
||||||
|
|
||||||
returncode, stdout, stderr = execute_compose_command(
|
returncode, stdout, stderr = execute_compose_command(
|
||||||
instance.compose_path, "restart"
|
instance.compose_path, "restart", env_file=env_file_path
|
||||||
)
|
)
|
||||||
|
|
||||||
if returncode == 0:
|
if returncode == 0:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -277,6 +278,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,8 @@
|
|||||||
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_include import ConfigInclude
|
||||||
|
from src.models.config_mount import ConfigMount
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.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 +11,17 @@ 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",
|
||||||
|
"ConfigInclude",
|
||||||
|
"ConfigMount",
|
||||||
|
"ConfigProfile",
|
||||||
|
"GitRepository",
|
||||||
|
"Project",
|
||||||
|
"SSHKey",
|
||||||
|
"ToolInstance",
|
||||||
|
"ToolType",
|
||||||
|
"User",
|
||||||
|
"UserConfig",
|
||||||
|
]
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class ConfigFolder(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
project_overrides: Mapped[dict | None] = mapped_column(
|
project_overrides: Mapped[dict | None] = mapped_column(
|
||||||
JSON, default=dict, nullable=True
|
JSON, default=dict, nullable=True
|
||||||
) # {"project_id": {"mount_path": "...", "files": {...}}}
|
) # {"project_id": {"mount_path": "...", "files": {...}}}
|
||||||
|
# DEPRECATED: Legacy auto-mounting flag. No longer used for launch-time
|
||||||
|
# auto-mounting. Use ConfigProfile and ToolInstance.selected_profile_id instead.
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship()
|
user: Mapped["User"] = relationship()
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, UniqueConstraint
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_includes"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
|
||||||
|
)
|
||||||
|
|
||||||
|
profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
included_profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[profile_id],
|
||||||
|
back_populates="includes",
|
||||||
|
)
|
||||||
|
included_profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[included_profile_id],
|
||||||
|
)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, JSON, String
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_mounts"
|
||||||
|
|
||||||
|
profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
target_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||||
|
mode: Mapped[str] = mapped_column(String(10), nullable=False, default="rw")
|
||||||
|
files: Mapped[dict[str, str] | None] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=True
|
||||||
|
)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[profile_id],
|
||||||
|
back_populates="mounts",
|
||||||
|
)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, JSON, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models.config_include import ConfigInclude
|
||||||
|
from src.models.config_mount import ConfigMount
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_profiles"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
project_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
environment_variables: Mapped[dict[str, str] | None] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=True
|
||||||
|
)
|
||||||
|
start_command: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
working_directory: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
is_default: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship()
|
||||||
|
project: Mapped["Project | None"] = relationship()
|
||||||
|
tool_type: Mapped["ToolType | None"] = relationship()
|
||||||
|
includes: Mapped[list["ConfigInclude"]] = relationship(
|
||||||
|
"ConfigInclude",
|
||||||
|
primaryjoin="ConfigProfile.id == ConfigInclude.profile_id",
|
||||||
|
back_populates="profile",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ConfigInclude.order_index",
|
||||||
|
)
|
||||||
|
mounts: Mapped[list["ConfigMount"]] = relationship(
|
||||||
|
"ConfigMount",
|
||||||
|
primaryjoin="ConfigProfile.id == ConfigMount.profile_id",
|
||||||
|
back_populates="profile",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ConfigMount.order_index",
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -62,8 +63,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
|
selected_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_profile: Mapped["ConfigProfile | None"] = relationship()
|
||||||
|
|||||||
@@ -18,3 +18,23 @@ class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
|
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship(back_populates="user_config")
|
user: Mapped["User"] = relationship(back_populates="user_config")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_profile_id(self) -> uuid.UUID | None:
|
||||||
|
profile_id = self.config.get("default_profile_id")
|
||||||
|
return uuid.UUID(profile_id) if profile_id else None
|
||||||
|
|
||||||
|
@default_profile_id.setter
|
||||||
|
def default_profile_id(self, value: uuid.UUID | None) -> None:
|
||||||
|
if value is not None:
|
||||||
|
self.config["default_profile_id"] = str(value)
|
||||||
|
elif "default_profile_id" in self.config:
|
||||||
|
del self.config["default_profile_id"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_profiles(self) -> dict[str, str]:
|
||||||
|
return self.config.get("default_profiles", {})
|
||||||
|
|
||||||
|
@default_profiles.setter
|
||||||
|
def default_profiles(self, value: dict[str, str]) -> None:
|
||||||
|
self.config["default_profiles"] = value
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""Profile resolver service for recursive ordered include resolution.
|
||||||
|
|
||||||
|
Provides deterministic merge rules, save-independent cycle protection,
|
||||||
|
and resolved output structures for env vars, runtime hints, mounts,
|
||||||
|
file trees, and override metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from src.models.config_include import ConfigInclude
|
||||||
|
from src.models.config_mount import ConfigMount
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedMount:
|
||||||
|
"""A resolved mount with merged file tree and final mode."""
|
||||||
|
|
||||||
|
target_path: str
|
||||||
|
mode: str # "ro" or "rw"
|
||||||
|
files: dict[str, str] = field(default_factory=dict)
|
||||||
|
"""Relative file paths to UTF-8 text content."""
|
||||||
|
overridden_files: dict[str, list[str]] = field(default_factory=dict)
|
||||||
|
"""Map of relative file path to list of profile names that contributed
|
||||||
|
(latest is the winner)."""
|
||||||
|
mode_overridden_by: str | None = None
|
||||||
|
"""Name of the profile that set the final mode, if different from first."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedRuntimeHints:
|
||||||
|
"""Resolved runtime hints from profile layers."""
|
||||||
|
|
||||||
|
start_command: str | None = None
|
||||||
|
working_directory: str | None = None
|
||||||
|
port: int | None = None
|
||||||
|
overridden_hints: dict[str, str] = field(default_factory=dict)
|
||||||
|
"""Map of hint key to profile name that provided the winning value."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedProfileOutput:
|
||||||
|
"""Complete resolved output for a config profile."""
|
||||||
|
|
||||||
|
profile_id: uuid.UUID
|
||||||
|
profile_name: str
|
||||||
|
environment_variables: dict[str, str] = field(default_factory=dict)
|
||||||
|
"""Final merged env vars (later layers win)."""
|
||||||
|
env_var_sources: dict[str, list[str]] = field(default_factory=dict)
|
||||||
|
"""Map of env var key to ordered list of contributing profile names
|
||||||
|
(latest is the winner)."""
|
||||||
|
runtime_hints: ResolvedRuntimeHints = field(
|
||||||
|
default_factory=lambda: ResolvedRuntimeHints()
|
||||||
|
)
|
||||||
|
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
|
||||||
|
"""Map of target_path to ResolvedMount."""
|
||||||
|
resolution_order: list[str] = field(default_factory=list)
|
||||||
|
"""Ordered list of profile names as they were resolved."""
|
||||||
|
cycle_detected: bool = False
|
||||||
|
cycle_path: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileResolutionError(Exception):
|
||||||
|
"""Raised when profile resolution fails."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileCycleError(ProfileResolutionError):
|
||||||
|
"""Raised when a cycle is detected during profile resolution."""
|
||||||
|
|
||||||
|
def __init__(self, cycle_path: list[str]) -> None:
|
||||||
|
self.cycle_path = cycle_path
|
||||||
|
path_str = " -> ".join(cycle_path)
|
||||||
|
super().__init__(f"Profile include cycle detected: {path_str}")
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_env_vars(
|
||||||
|
current: dict[str, str],
|
||||||
|
sources: dict[str, list[str]],
|
||||||
|
profile: ConfigProfile,
|
||||||
|
) -> None:
|
||||||
|
"""Merge a profile's env vars into the current dict, tracking sources."""
|
||||||
|
if not profile.environment_variables:
|
||||||
|
return
|
||||||
|
for key, value in profile.environment_variables.items():
|
||||||
|
current[key] = value
|
||||||
|
if key not in sources:
|
||||||
|
sources[key] = []
|
||||||
|
sources[key].append(profile.name)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_runtime_hints(
|
||||||
|
hints: ResolvedRuntimeHints,
|
||||||
|
profile: ConfigProfile,
|
||||||
|
) -> None:
|
||||||
|
"""Merge a profile's runtime hints, tracking overrides."""
|
||||||
|
if profile.start_command is not None:
|
||||||
|
hints.start_command = profile.start_command
|
||||||
|
hints.overridden_hints["start_command"] = profile.name
|
||||||
|
if profile.working_directory is not None:
|
||||||
|
hints.working_directory = profile.working_directory
|
||||||
|
hints.overridden_hints["working_directory"] = profile.name
|
||||||
|
if profile.port is not None:
|
||||||
|
hints.port = profile.port
|
||||||
|
hints.overridden_hints["port"] = profile.name
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_mounts(
|
||||||
|
mounts: dict[str, ResolvedMount],
|
||||||
|
profile_mounts: list[ConfigMount],
|
||||||
|
profile: ConfigProfile,
|
||||||
|
) -> None:
|
||||||
|
"""Merge a profile's mounts into the current mounts dict."""
|
||||||
|
for mount in profile_mounts:
|
||||||
|
target = mount.target_path
|
||||||
|
if target not in mounts:
|
||||||
|
mounts[target] = ResolvedMount(
|
||||||
|
target_path=target,
|
||||||
|
mode=mount.mode,
|
||||||
|
files={},
|
||||||
|
overridden_files={},
|
||||||
|
)
|
||||||
|
resolved = mounts[target]
|
||||||
|
|
||||||
|
# Mode override: later wins
|
||||||
|
if resolved.mode != mount.mode:
|
||||||
|
resolved.mode = mount.mode
|
||||||
|
resolved.mode_overridden_by = profile.name
|
||||||
|
|
||||||
|
# File tree merge: later wins for same relative path
|
||||||
|
if mount.files:
|
||||||
|
for rel_path, content in mount.files.items():
|
||||||
|
if rel_path not in resolved.files:
|
||||||
|
resolved.overridden_files[rel_path] = []
|
||||||
|
else:
|
||||||
|
if rel_path not in resolved.overridden_files:
|
||||||
|
resolved.overridden_files[rel_path] = []
|
||||||
|
resolved.overridden_files[rel_path].append(profile.name)
|
||||||
|
resolved.files[rel_path] = content
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_profile_recursive(
|
||||||
|
profile: ConfigProfile,
|
||||||
|
visited: set[uuid.UUID],
|
||||||
|
path: list[str],
|
||||||
|
resolution_order: list[str],
|
||||||
|
env_vars: dict[str, str],
|
||||||
|
env_var_sources: dict[str, list[str]],
|
||||||
|
runtime_hints: ResolvedRuntimeHints,
|
||||||
|
mounts: dict[str, ResolvedMount],
|
||||||
|
) -> None:
|
||||||
|
"""Recursively resolve a profile and its includes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: The profile to resolve
|
||||||
|
visited: Set of already-resolved profile IDs to avoid duplicates
|
||||||
|
path: Current recursion path for cycle detection
|
||||||
|
resolution_order: Ordered list of profile names being resolved
|
||||||
|
env_vars: Accumulated environment variables
|
||||||
|
env_var_sources: Tracking of which profiles contributed each env var
|
||||||
|
runtime_hints: Accumulated runtime hints
|
||||||
|
mounts: Accumulated mounts
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProfileCycleError: If a cycle is detected
|
||||||
|
"""
|
||||||
|
if profile.name in path:
|
||||||
|
# Cycle detected
|
||||||
|
cycle_start = path.index(profile.name)
|
||||||
|
cycle_path = path[cycle_start:] + [profile.name]
|
||||||
|
raise ProfileCycleError(cycle_path)
|
||||||
|
|
||||||
|
if profile.id in visited:
|
||||||
|
# Already resolved in another branch (diamond graph)
|
||||||
|
return
|
||||||
|
|
||||||
|
visited.add(profile.id)
|
||||||
|
path.append(profile.name)
|
||||||
|
resolution_order.append(profile.name)
|
||||||
|
|
||||||
|
# Resolve includes first (in order)
|
||||||
|
includes: list[ConfigInclude] = list(profile.includes)
|
||||||
|
includes.sort(key=lambda inc: inc.order_index)
|
||||||
|
for include in includes:
|
||||||
|
included_profile = include.included_profile
|
||||||
|
if included_profile is not None:
|
||||||
|
_resolve_profile_recursive(
|
||||||
|
included_profile,
|
||||||
|
visited,
|
||||||
|
path,
|
||||||
|
resolution_order,
|
||||||
|
env_vars,
|
||||||
|
env_var_sources,
|
||||||
|
runtime_hints,
|
||||||
|
mounts,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply this profile's values (later layers win)
|
||||||
|
_merge_env_vars(env_vars, env_var_sources, profile)
|
||||||
|
_merge_runtime_hints(runtime_hints, profile)
|
||||||
|
_merge_mounts(mounts, list(profile.mounts), profile)
|
||||||
|
|
||||||
|
path.pop()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_profile(profile: ConfigProfile) -> ResolvedProfileOutput:
|
||||||
|
"""Resolve a config profile with all its includes.
|
||||||
|
|
||||||
|
Processes included profiles in configured order, then applies the
|
||||||
|
selected profile itself. Later layers override earlier layers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: The root profile to resolve
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ResolvedProfileOutput with merged env vars, runtime hints, mounts,
|
||||||
|
and override metadata
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProfileCycleError: If a cycle is detected in the include graph
|
||||||
|
"""
|
||||||
|
env_vars: dict[str, str] = {}
|
||||||
|
env_var_sources: dict[str, list[str]] = {}
|
||||||
|
runtime_hints = ResolvedRuntimeHints()
|
||||||
|
mounts: dict[str, ResolvedMount] = {}
|
||||||
|
resolution_order: list[str] = []
|
||||||
|
|
||||||
|
_resolve_profile_recursive(
|
||||||
|
profile,
|
||||||
|
set(),
|
||||||
|
[],
|
||||||
|
resolution_order,
|
||||||
|
env_vars,
|
||||||
|
env_var_sources,
|
||||||
|
runtime_hints,
|
||||||
|
mounts,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResolvedProfileOutput(
|
||||||
|
profile_id=profile.id,
|
||||||
|
profile_name=profile.name,
|
||||||
|
environment_variables=env_vars,
|
||||||
|
env_var_sources=env_var_sources,
|
||||||
|
runtime_hints=runtime_hints,
|
||||||
|
mounts=mounts,
|
||||||
|
resolution_order=resolution_order,
|
||||||
|
)
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
"""Integration tests for config profiles API."""
|
||||||
|
|
||||||
|
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, dict)
|
||||||
|
assert "profiles" in data
|
||||||
|
assert isinstance(data["profiles"], 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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "test-profile"
|
||||||
|
assert data["description"] == "Test profile"
|
||||||
|
|
||||||
|
def test_create_config_profile_duplicate_name(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that duplicate profile names are rejected."""
|
||||||
|
authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "duplicate-profile"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "duplicate-profile"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
def test_create_config_profile_empty_name(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that empty profile names are rejected."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": " "},
|
||||||
|
)
|
||||||
|
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_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "get-test"},
|
||||||
|
)
|
||||||
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "get-test"
|
||||||
|
assert "includes" in data
|
||||||
|
assert "mounts" in data
|
||||||
|
|
||||||
|
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_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "update-test"},
|
||||||
|
)
|
||||||
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-profiles/{profile_id}",
|
||||||
|
json={"name": "updated-name", "description": "updated desc"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "updated-name"
|
||||||
|
assert data["description"] == "updated desc"
|
||||||
|
|
||||||
|
def test_delete_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test deleting a config profile."""
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "delete-test"},
|
||||||
|
)
|
||||||
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
response = authenticated_client.delete(f"/config-profiles/{profile_id}")
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
get_response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
||||||
|
assert get_response.status_code == 404
|
||||||
|
|
||||||
|
def test_profile_access_check(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that users can only access their own profiles."""
|
||||||
|
# Create a profile
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "access-test"},
|
||||||
|
)
|
||||||
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# The profile should be accessible
|
||||||
|
response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestConfigProfileIncludes:
|
||||||
|
"""Integration tests for config profile includes."""
|
||||||
|
|
||||||
|
def test_add_include_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test adding an include to a profile."""
|
||||||
|
# Create two profiles
|
||||||
|
profile1 = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "profile-1"},
|
||||||
|
).json()
|
||||||
|
profile2 = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "profile-2"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
# Add include
|
||||||
|
response = authenticated_client.post(
|
||||||
|
f"/config-profiles/{profile1['id']}/includes",
|
||||||
|
json={"included_profile_id": profile2["id"], "order_index": 0},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["included_profile_id"] == profile2["id"]
|
||||||
|
assert data["included_profile_name"] == "profile-2"
|
||||||
|
|
||||||
|
def test_add_self_include_rejected(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that self-includes are rejected."""
|
||||||
|
profile = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "self-include-test"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
response = authenticated_client.post(
|
||||||
|
f"/config-profiles/{profile['id']}/includes",
|
||||||
|
json={"included_profile_id": profile["id"], "order_index": 0},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_add_include_cycle_rejected(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that circular includes are rejected."""
|
||||||
|
profile1 = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "cycle-1"},
|
||||||
|
).json()
|
||||||
|
profile2 = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "cycle-2"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
# Add profile1 includes profile2
|
||||||
|
authenticated_client.post(
|
||||||
|
f"/config-profiles/{profile1['id']}/includes",
|
||||||
|
json={"included_profile_id": profile2["id"], "order_index": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to add profile2 includes profile1 (creates cycle)
|
||||||
|
response = authenticated_client.post(
|
||||||
|
f"/config-profiles/{profile2['id']}/includes",
|
||||||
|
json={"included_profile_id": profile1["id"], "order_index": 0},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_add_deep_cycle_rejected(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that deep circular includes are rejected."""
|
||||||
|
p1 = authenticated_client.post(
|
||||||
|
"/config-profiles", json={"name": "deep-1"}
|
||||||
|
).json()
|
||||||
|
p2 = authenticated_client.post(
|
||||||
|
"/config-profiles", json={"name": "deep-2"}
|
||||||
|
).json()
|
||||||
|
p3 = authenticated_client.post(
|
||||||
|
"/config-profiles", json={"name": "deep-3"}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
# p1 -> p2 -> p3
|
||||||
|
authenticated_client.post(
|
||||||
|
f"/config-profiles/{p1['id']}/includes",
|
||||||
|
json={"included_profile_id": p2["id"], "order_index": 0},
|
||||||
|
)
|
||||||
|
authenticated_client.post(
|
||||||
|
f"/config-profiles/{p2['id']}/includes",
|
||||||
|
json={"included_profile_id": p3["id"], "order_index": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try p3 -> p1 (creates cycle)
|
||||||
|
response = authenticated_client.post(
|
||||||
|
f"/config-profiles/{p3['id']}/includes",
|
||||||
|
json={"included_profile_id": p1["id"], "order_index": 0},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_add_duplicate_include_rejected(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that duplicate includes are rejected."""
|
||||||
|
p1 = authenticated_client.post(
|
||||||
|
"/config-profiles", json={"name": "dup-1"}
|
||||||
|
).json()
|
||||||
|
p2 = authenticated_client.post(
|
||||||
|
"/config-profiles", json={"name": "dup-2"}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
authenticated_client.post(
|
||||||
|
f"/config-profiles/{p1['id']}/includes",
|
||||||
|
json={"included_profile_id": p2["id"], "order_index": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = authenticated_client.post(
|
||||||
|
f"/config-profiles/{p1['id']}/includes",
|
||||||
|
json={"included_profile_id": p2["id"], "order_index": 1},
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
def test_list_includes(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test listing includes for a profile."""
|
||||||
|
p1 = authenticated_client.post(
|
||||||
|
"/config-profiles", json={"name": "list-inc-1"}
|
||||||
|
).json()
|
||||||
|
p2 = authenticated_client.post(
|
||||||
|
"/config-profiles", json={"name": "list-inc-2"}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
authenticated_client.post(
|
||||||
|
f"/config-profiles/{p1['id']}/includes",
|
||||||
|
json={"included_profile_id": p2["id"], "order_index": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = authenticated_client.get(f"/config-profiles/{p1['id']}/includes")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["includes"]) == 1
|
||||||
|
|
||||||
|
def test_update_include_order(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test updating include order index."""
|
||||||
|
p1 = authenticated_client.post(
|
||||||
|
"/config-profiles", json={"name": "order-1"}
|
||||||
|
).json()
|
||||||
|
p2 = authenticated_client.post(
|
||||||
|
"/config-profiles", json={"name": "order-2"}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
inc = authenticated_client.post(
|
||||||
|
f"/config-profiles/{p1['id']}/includes",
|
||||||
|
json={"included_profile_id": p2["id"], "order_index": 0},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-profiles/{p1['id']}/includes/{inc['id']}",
|
||||||
|
json={"order_index": 5},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["order_index"] == 5
|
||||||
|
|
||||||
|
def test_remove_include(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test removing an include."""
|
||||||
|
p1 = authenticated_client.post(
|
||||||
|
"/config-profiles", json={"name": "rem-1"}
|
||||||
|
).json()
|
||||||
|
p2 = authenticated_client.post(
|
||||||
|
"/config-profiles", json={"name": "rem-2"}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
inc = authenticated_client.post(
|
||||||
|
f"/config-profiles/{p1['id']}/includes",
|
||||||
|
json={"included_profile_id": p2["id"], "order_index": 0},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
response = authenticated_client.delete(
|
||||||
|
f"/config-profiles/{p1['id']}/includes/{inc['id']}"
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestConfigProfileMounts:
|
||||||
|
"""Integration tests for config profile mounts."""
|
||||||
|
|
||||||
|
def test_add_mount_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test adding a mount to a profile."""
|
||||||
|
profile = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "mount-test"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
response = authenticated_client.post(
|
||||||
|
f"/config-profiles/{profile['id']}/mounts",
|
||||||
|
json={"target_path": "/etc/config", "files": {"test.txt": "hello"}, "order_index": 0},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["target_path"] == "/etc/config"
|
||||||
|
assert data["files"] == {"test.txt": "hello"}
|
||||||
|
|
||||||
|
def test_add_mount_relative_path_rejected(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that relative mount paths are rejected."""
|
||||||
|
profile = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "rel-path-test"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
response = authenticated_client.post(
|
||||||
|
f"/config-profiles/{profile['id']}/mounts",
|
||||||
|
json={"target_path": "etc/config", "files": {"test.txt": "hello"}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_add_target_path_traversal_rejected(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that path traversal in mount paths is rejected."""
|
||||||
|
profile = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "traversal-test"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
response = authenticated_client.post(
|
||||||
|
f"/config-profiles/{profile['id']}/mounts",
|
||||||
|
json={"target_path": "/etc/../passwd", "files": {"test.txt": "hello"}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_add_duplicate_mount_rejected(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that duplicate mount paths are rejected."""
|
||||||
|
profile = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "dup-mount-test"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
authenticated_client.post(
|
||||||
|
f"/config-profiles/{profile['id']}/mounts",
|
||||||
|
json={"target_path": "/etc/config", "files": {"test.txt": "hello"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = authenticated_client.post(
|
||||||
|
f"/config-profiles/{profile['id']}/mounts",
|
||||||
|
json={"target_path": "/etc/config", "files": {"test.txt": "world"}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
def test_update_mount(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test updating a mount."""
|
||||||
|
profile = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "update-mount-test"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
mount = authenticated_client.post(
|
||||||
|
f"/config-profiles/{profile['id']}/mounts",
|
||||||
|
json={"target_path": "/old/path", "files": {"test.txt": "old"}},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-profiles/{profile['id']}/mounts/{mount['id']}",
|
||||||
|
json={"target_path": "/new/path", "files": {"test.txt": "new"}, "order_index": 2},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["target_path"] == "/new/path"
|
||||||
|
assert data["files"] == {"test.txt": "new"}
|
||||||
|
assert data["order_index"] == 2
|
||||||
|
|
||||||
|
def test_remove_mount(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test removing a mount."""
|
||||||
|
profile = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "rem-mount-test"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
mount = authenticated_client.post(
|
||||||
|
f"/config-profiles/{profile['id']}/mounts",
|
||||||
|
json={"target_path": "/tmp/test", "files": {"test.txt": "x"}},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
response = authenticated_client.delete(
|
||||||
|
f"/config-profiles/{profile['id']}/mounts/{mount['id']}"
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestConfigProfileDefaults:
|
||||||
|
"""Integration tests for default profile APIs."""
|
||||||
|
|
||||||
|
def test_get_default_profiles_empty(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test getting default profiles when none are set."""
|
||||||
|
response = authenticated_client.get("/config-profiles/defaults")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["default_profiles"] == {}
|
||||||
|
|
||||||
|
def test_set_default_profiles(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test setting default profiles."""
|
||||||
|
profile = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "default-test"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
response = authenticated_client.put(
|
||||||
|
"/config-profiles/defaults",
|
||||||
|
json={"default_profiles": {"code-server": profile["id"]}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["default_profiles"]["code-server"] == profile["id"]
|
||||||
|
|
||||||
|
def test_set_default_profiles_invalid_profile(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test setting default profiles with invalid profile ID."""
|
||||||
|
response = authenticated_client.put(
|
||||||
|
"/config-profiles/defaults",
|
||||||
|
json={"default_profiles": {"code-server": str(uuid.uuid4())}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_get_default_profile_for_tool_type(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test getting default profile for a specific tool type."""
|
||||||
|
profile = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={"name": "tool-default-test"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
authenticated_client.put(
|
||||||
|
"/config-profiles/defaults",
|
||||||
|
json={"default_profiles": {"jupyter-notebook": profile["id"]}},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = authenticated_client.get("/config-profiles/defaults/jupyter-notebook")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["tool_type_id"] == "jupyter-notebook"
|
||||||
|
assert data["profile_id"] == profile["id"]
|
||||||
|
|
||||||
|
def test_get_default_profile_for_tool_type_not_set(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test getting default profile when not set."""
|
||||||
|
response = authenticated_client.get("/config-profiles/defaults/opencode")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["tool_type_id"] == "opencode"
|
||||||
|
assert data["profile_id"] is None
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -58,7 +58,7 @@ def _mint_token(user_id: str) -> str:
|
|||||||
subject=user_id,
|
subject=user_id,
|
||||||
email="test@headquarter.local",
|
email="test@headquarter.local",
|
||||||
name="Test User",
|
name="Test User",
|
||||||
expires_at=datetime.now(UTC) + timedelta(minutes=15),
|
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -59,7 +59,7 @@ def _mint_token(user_id: str) -> str:
|
|||||||
subject=user_id,
|
subject=user_id,
|
||||||
email="test@headquarter.local",
|
email="test@headquarter.local",
|
||||||
name="Test User",
|
name="Test User",
|
||||||
expires_at=datetime.now(UTC) + timedelta(minutes=15),
|
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ def _create_auth_cookie(user_id: str) -> str:
|
|||||||
subject=user_id,
|
subject=user_id,
|
||||||
email="test@headquarter.local",
|
email="test@headquarter.local",
|
||||||
name="Test User",
|
name="Test User",
|
||||||
expires_at=datetime.now(UTC) + timedelta(minutes=15),
|
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,3 +39,18 @@ def test_refresh_tokens_migration_has_expected_revision_chain() -> None:
|
|||||||
|
|
||||||
assert module.revision == "0002_refresh_tokens"
|
assert module.revision == "0002_refresh_tokens"
|
||||||
assert module.down_revision == "0001_initial_schema"
|
assert module.down_revision == "0001_initial_schema"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_config_profiles_migration_has_expected_revision_chain() -> None:
|
||||||
|
migration_path = Path(__file__).resolve().parents[2] / "alembic" / "versions" / "0013_add_config_profiles.py"
|
||||||
|
spec = spec_from_file_location("add_config_profiles", migration_path)
|
||||||
|
|
||||||
|
assert spec is not None
|
||||||
|
assert spec.loader is not None
|
||||||
|
|
||||||
|
module = module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
assert module.revision == "0013_add_config_profiles"
|
||||||
|
assert module.down_revision == "0012_default_port_req"
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
"""Unit tests for the profile resolver service."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services.profile_resolver import (
|
||||||
|
ProfileCycleError,
|
||||||
|
ResolvedProfileOutput,
|
||||||
|
resolve_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_profile(
|
||||||
|
name: str,
|
||||||
|
env_vars: dict[str, str] | None = None,
|
||||||
|
start_command: str | None = None,
|
||||||
|
working_directory: str | None = None,
|
||||||
|
port: int | None = None,
|
||||||
|
mounts: list[MagicMock] | None = None,
|
||||||
|
includes: list[MagicMock] | None = None,
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Create a mock ConfigProfile for testing."""
|
||||||
|
profile = MagicMock()
|
||||||
|
profile.id = uuid.uuid4()
|
||||||
|
profile.name = name
|
||||||
|
profile.environment_variables = env_vars or {}
|
||||||
|
profile.start_command = start_command
|
||||||
|
profile.working_directory = working_directory
|
||||||
|
profile.port = port
|
||||||
|
profile.mounts = mounts or []
|
||||||
|
profile.includes = includes or []
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def _make_include(included_profile: MagicMock, order_index: int = 0) -> MagicMock:
|
||||||
|
"""Create a mock ConfigInclude for testing."""
|
||||||
|
include = MagicMock()
|
||||||
|
include.included_profile = included_profile
|
||||||
|
include.order_index = order_index
|
||||||
|
return include
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mount(
|
||||||
|
target_path: str,
|
||||||
|
mode: str = "rw",
|
||||||
|
files: dict[str, str] | None = None,
|
||||||
|
order_index: int = 0,
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Create a mock ConfigMount for testing."""
|
||||||
|
mount = MagicMock()
|
||||||
|
mount.target_path = target_path
|
||||||
|
mount.mode = mode
|
||||||
|
mount.files = files or {}
|
||||||
|
mount.order_index = order_index
|
||||||
|
return mount
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveProfileBasic:
|
||||||
|
"""Tests for basic profile resolution without includes."""
|
||||||
|
|
||||||
|
def test_empty_profile(self) -> None:
|
||||||
|
"""Resolving an empty profile returns empty output."""
|
||||||
|
profile = _make_profile("empty")
|
||||||
|
result = resolve_profile(profile)
|
||||||
|
|
||||||
|
assert isinstance(result, ResolvedProfileOutput)
|
||||||
|
assert result.profile_name == "empty"
|
||||||
|
assert result.environment_variables == {}
|
||||||
|
assert result.runtime_hints.start_command is None
|
||||||
|
assert result.runtime_hints.working_directory is None
|
||||||
|
assert result.runtime_hints.port is None
|
||||||
|
assert result.mounts == {}
|
||||||
|
assert result.resolution_order == ["empty"]
|
||||||
|
|
||||||
|
def test_env_vars_only(self) -> None:
|
||||||
|
"""Profile with env vars resolves correctly."""
|
||||||
|
profile = _make_profile(
|
||||||
|
"env-only",
|
||||||
|
env_vars={"FOO": "bar", "BAZ": "qux"},
|
||||||
|
)
|
||||||
|
result = resolve_profile(profile)
|
||||||
|
|
||||||
|
assert result.environment_variables == {"FOO": "bar", "BAZ": "qux"}
|
||||||
|
assert result.env_var_sources == {
|
||||||
|
"FOO": ["env-only"],
|
||||||
|
"BAZ": ["env-only"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_runtime_hints_only(self) -> None:
|
||||||
|
"""Profile with runtime hints resolves correctly."""
|
||||||
|
profile = _make_profile(
|
||||||
|
"hints-only",
|
||||||
|
start_command="python app.py",
|
||||||
|
working_directory="/app",
|
||||||
|
port=8080,
|
||||||
|
)
|
||||||
|
result = resolve_profile(profile)
|
||||||
|
|
||||||
|
assert result.runtime_hints.start_command == "python app.py"
|
||||||
|
assert result.runtime_hints.working_directory == "/app"
|
||||||
|
assert result.runtime_hints.port == 8080
|
||||||
|
assert result.runtime_hints.overridden_hints == {
|
||||||
|
"start_command": "hints-only",
|
||||||
|
"working_directory": "hints-only",
|
||||||
|
"port": "hints-only",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_mounts_only(self) -> None:
|
||||||
|
"""Profile with mounts resolves correctly."""
|
||||||
|
profile = _make_profile(
|
||||||
|
"mounts-only",
|
||||||
|
mounts=[
|
||||||
|
_make_mount(
|
||||||
|
"/config",
|
||||||
|
mode="ro",
|
||||||
|
files={"settings.json": '{"key": "value"}'},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = resolve_profile(profile)
|
||||||
|
|
||||||
|
assert "/config" in result.mounts
|
||||||
|
mount = result.mounts["/config"]
|
||||||
|
assert mount.target_path == "/config"
|
||||||
|
assert mount.mode == "ro"
|
||||||
|
assert mount.files == {"settings.json": '{"key": "value"}'}
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveProfileIncludes:
|
||||||
|
"""Tests for profile resolution with includes."""
|
||||||
|
|
||||||
|
def test_single_include(self) -> None:
|
||||||
|
"""Profile with one include resolves in correct order."""
|
||||||
|
base = _make_profile("base", env_vars={"FOO": "base"})
|
||||||
|
derived = _make_profile(
|
||||||
|
"derived",
|
||||||
|
env_vars={"BAR": "derived"},
|
||||||
|
includes=[_make_include(base, order_index=0)],
|
||||||
|
)
|
||||||
|
result = resolve_profile(derived)
|
||||||
|
|
||||||
|
assert result.resolution_order == ["derived", "base"]
|
||||||
|
assert result.environment_variables == {
|
||||||
|
"FOO": "base",
|
||||||
|
"BAR": "derived",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_multiple_includes_ordered(self) -> None:
|
||||||
|
"""Multiple includes are resolved in order_index order."""
|
||||||
|
first = _make_profile("first", env_vars={"KEY": "first"})
|
||||||
|
second = _make_profile("second", env_vars={"KEY": "second"})
|
||||||
|
main = _make_profile(
|
||||||
|
"main",
|
||||||
|
includes=[
|
||||||
|
_make_include(first, order_index=0),
|
||||||
|
_make_include(second, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = resolve_profile(main)
|
||||||
|
|
||||||
|
assert result.resolution_order == ["main", "first", "second"]
|
||||||
|
# second overrides first
|
||||||
|
assert result.environment_variables == {"KEY": "second"}
|
||||||
|
assert result.env_var_sources["KEY"] == ["first", "second"]
|
||||||
|
|
||||||
|
def test_include_order_matters(self) -> None:
|
||||||
|
"""Changing include order changes resolution."""
|
||||||
|
a = _make_profile("a", env_vars={"KEY": "a"})
|
||||||
|
b = _make_profile("b", env_vars={"KEY": "b"})
|
||||||
|
main1 = _make_profile(
|
||||||
|
"main",
|
||||||
|
includes=[
|
||||||
|
_make_include(a, order_index=0),
|
||||||
|
_make_include(b, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
main2 = _make_profile(
|
||||||
|
"main",
|
||||||
|
includes=[
|
||||||
|
_make_include(b, order_index=0),
|
||||||
|
_make_include(a, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
result1 = resolve_profile(main1)
|
||||||
|
result2 = resolve_profile(main2)
|
||||||
|
|
||||||
|
assert result1.environment_variables["KEY"] == "b"
|
||||||
|
assert result2.environment_variables["KEY"] == "a"
|
||||||
|
|
||||||
|
def test_nested_includes(self) -> None:
|
||||||
|
"""Deeply nested includes resolve recursively."""
|
||||||
|
deep = _make_profile("deep", env_vars={"DEEP": "value"})
|
||||||
|
mid = _make_profile(
|
||||||
|
"mid",
|
||||||
|
env_vars={"MID": "value"},
|
||||||
|
includes=[_make_include(deep, order_index=0)],
|
||||||
|
)
|
||||||
|
top = _make_profile(
|
||||||
|
"top",
|
||||||
|
env_vars={"TOP": "value"},
|
||||||
|
includes=[_make_include(mid, order_index=0)],
|
||||||
|
)
|
||||||
|
result = resolve_profile(top)
|
||||||
|
|
||||||
|
assert result.resolution_order == ["top", "mid", "deep"]
|
||||||
|
assert result.environment_variables == {
|
||||||
|
"TOP": "value",
|
||||||
|
"MID": "value",
|
||||||
|
"DEEP": "value",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveProfileOverrides:
|
||||||
|
"""Tests for deterministic override rules."""
|
||||||
|
|
||||||
|
def test_env_var_override(self) -> None:
|
||||||
|
"""Later layers override earlier env vars."""
|
||||||
|
base = _make_profile("base", env_vars={"KEY": "base"})
|
||||||
|
override = _make_profile("override", env_vars={"KEY": "override"})
|
||||||
|
main = _make_profile(
|
||||||
|
"main",
|
||||||
|
includes=[
|
||||||
|
_make_include(base, order_index=0),
|
||||||
|
_make_include(override, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = resolve_profile(main)
|
||||||
|
|
||||||
|
assert result.environment_variables["KEY"] == "override"
|
||||||
|
assert result.env_var_sources["KEY"] == ["base", "override"]
|
||||||
|
|
||||||
|
def test_main_profile_wins_over_includes(self) -> None:
|
||||||
|
"""The main profile itself wins over all includes."""
|
||||||
|
base = _make_profile("base", env_vars={"KEY": "base"})
|
||||||
|
main = _make_profile(
|
||||||
|
"main",
|
||||||
|
env_vars={"KEY": "main"},
|
||||||
|
includes=[_make_include(base, order_index=0)],
|
||||||
|
)
|
||||||
|
result = resolve_profile(main)
|
||||||
|
|
||||||
|
assert result.environment_variables["KEY"] == "main"
|
||||||
|
assert result.env_var_sources["KEY"] == ["base", "main"]
|
||||||
|
|
||||||
|
def test_runtime_hint_override(self) -> None:
|
||||||
|
"""Later layers override earlier runtime hints."""
|
||||||
|
base = _make_profile("base", start_command="python old.py")
|
||||||
|
override = _make_profile("override", start_command="python new.py")
|
||||||
|
main = _make_profile(
|
||||||
|
"main",
|
||||||
|
includes=[
|
||||||
|
_make_include(base, order_index=0),
|
||||||
|
_make_include(override, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = resolve_profile(main)
|
||||||
|
|
||||||
|
assert result.runtime_hints.start_command == "python new.py"
|
||||||
|
assert result.runtime_hints.overridden_hints["start_command"] == "override"
|
||||||
|
|
||||||
|
def test_mount_file_override(self) -> None:
|
||||||
|
"""Later layers override earlier files in the same mount."""
|
||||||
|
base = _make_profile(
|
||||||
|
"base",
|
||||||
|
mounts=[
|
||||||
|
_make_mount(
|
||||||
|
"/config",
|
||||||
|
files={"app.json": '{"v": 1}'},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
override = _make_profile(
|
||||||
|
"override",
|
||||||
|
mounts=[
|
||||||
|
_make_mount(
|
||||||
|
"/config",
|
||||||
|
files={"app.json": '{"v": 2}'},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
main = _make_profile(
|
||||||
|
"main",
|
||||||
|
includes=[
|
||||||
|
_make_include(base, order_index=0),
|
||||||
|
_make_include(override, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = resolve_profile(main)
|
||||||
|
|
||||||
|
mount = result.mounts["/config"]
|
||||||
|
assert mount.files["app.json"] == '{"v": 2}'
|
||||||
|
assert mount.overridden_files["app.json"] == ["override"]
|
||||||
|
|
||||||
|
def test_mount_mode_override(self) -> None:
|
||||||
|
"""Later layers override mount mode."""
|
||||||
|
base = _make_profile(
|
||||||
|
"base",
|
||||||
|
mounts=[_make_mount("/data", mode="ro")],
|
||||||
|
)
|
||||||
|
override = _make_profile(
|
||||||
|
"override",
|
||||||
|
mounts=[_make_mount("/data", mode="rw")],
|
||||||
|
)
|
||||||
|
main = _make_profile(
|
||||||
|
"main",
|
||||||
|
includes=[
|
||||||
|
_make_include(base, order_index=0),
|
||||||
|
_make_include(override, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = resolve_profile(main)
|
||||||
|
|
||||||
|
assert result.mounts["/data"].mode == "rw"
|
||||||
|
assert result.mounts["/data"].mode_overridden_by == "override"
|
||||||
|
|
||||||
|
def test_mount_file_merge(self) -> None:
|
||||||
|
"""Different files in the same mount are merged."""
|
||||||
|
base = _make_profile(
|
||||||
|
"base",
|
||||||
|
mounts=[
|
||||||
|
_make_mount(
|
||||||
|
"/config",
|
||||||
|
files={"a.json": "1"},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
override = _make_profile(
|
||||||
|
"override",
|
||||||
|
mounts=[
|
||||||
|
_make_mount(
|
||||||
|
"/config",
|
||||||
|
files={"b.json": "2"},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
main = _make_profile(
|
||||||
|
"main",
|
||||||
|
includes=[
|
||||||
|
_make_include(base, order_index=0),
|
||||||
|
_make_include(override, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = resolve_profile(main)
|
||||||
|
|
||||||
|
mount = result.mounts["/config"]
|
||||||
|
assert mount.files == {"a.json": "1", "b.json": "2"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveProfileCycles:
|
||||||
|
"""Tests for cycle detection during resolution."""
|
||||||
|
|
||||||
|
def test_direct_cycle(self) -> None:
|
||||||
|
"""A -> B -> A is detected."""
|
||||||
|
a = _make_profile("a")
|
||||||
|
b = _make_profile("b", includes=[_make_include(a, order_index=0)])
|
||||||
|
a.includes = [_make_include(b, order_index=0)]
|
||||||
|
|
||||||
|
with pytest.raises(ProfileCycleError) as exc_info:
|
||||||
|
resolve_profile(a)
|
||||||
|
|
||||||
|
assert "a" in exc_info.value.cycle_path
|
||||||
|
assert "b" in exc_info.value.cycle_path
|
||||||
|
|
||||||
|
def test_indirect_cycle(self) -> None:
|
||||||
|
"""A -> B -> C -> A is detected."""
|
||||||
|
a = _make_profile("a")
|
||||||
|
c = _make_profile("c")
|
||||||
|
b = _make_profile("b", includes=[_make_include(c, order_index=0)])
|
||||||
|
a.includes = [_make_include(b, order_index=0)]
|
||||||
|
c.includes = [_make_include(a, order_index=0)]
|
||||||
|
|
||||||
|
with pytest.raises(ProfileCycleError) as exc_info:
|
||||||
|
resolve_profile(a)
|
||||||
|
|
||||||
|
assert "a" in exc_info.value.cycle_path
|
||||||
|
assert "b" in exc_info.value.cycle_path
|
||||||
|
assert "c" in exc_info.value.cycle_path
|
||||||
|
|
||||||
|
def test_self_cycle(self) -> None:
|
||||||
|
"""A -> A is detected."""
|
||||||
|
a = _make_profile("a")
|
||||||
|
a.includes = [_make_include(a, order_index=0)]
|
||||||
|
|
||||||
|
with pytest.raises(ProfileCycleError) as exc_info:
|
||||||
|
resolve_profile(a)
|
||||||
|
|
||||||
|
assert exc_info.value.cycle_path == ["a", "a"]
|
||||||
|
|
||||||
|
def test_cycle_does_not_partially_resolve(self) -> None:
|
||||||
|
"""Cycle detection prevents any partial resolution."""
|
||||||
|
a = _make_profile("a", env_vars={"A": "a"})
|
||||||
|
b = _make_profile("b", env_vars={"B": "b"})
|
||||||
|
a.includes = [_make_include(b, order_index=0)]
|
||||||
|
b.includes = [_make_include(a, order_index=0)]
|
||||||
|
|
||||||
|
with pytest.raises(ProfileCycleError):
|
||||||
|
resolve_profile(a)
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveProfileDiamond:
|
||||||
|
"""Tests for diamond-shaped include graphs."""
|
||||||
|
|
||||||
|
def test_diamond_resolution(self) -> None:
|
||||||
|
"""Diamond graph resolves correctly without duplication issues."""
|
||||||
|
base = _make_profile("base", env_vars={"BASE": "base"})
|
||||||
|
left = _make_profile(
|
||||||
|
"left",
|
||||||
|
env_vars={"LEFT": "left"},
|
||||||
|
includes=[_make_include(base, order_index=0)],
|
||||||
|
)
|
||||||
|
right = _make_profile(
|
||||||
|
"right",
|
||||||
|
env_vars={"RIGHT": "right"},
|
||||||
|
includes=[_make_include(base, order_index=0)],
|
||||||
|
)
|
||||||
|
top = _make_profile(
|
||||||
|
"top",
|
||||||
|
env_vars={"TOP": "top"},
|
||||||
|
includes=[
|
||||||
|
_make_include(left, order_index=0),
|
||||||
|
_make_include(right, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = resolve_profile(top)
|
||||||
|
|
||||||
|
# base should appear once (via left, then right skips because visited)
|
||||||
|
assert result.resolution_order == ["top", "left", "base", "right"]
|
||||||
|
assert result.environment_variables == {
|
||||||
|
"TOP": "top",
|
||||||
|
"LEFT": "left",
|
||||||
|
"RIGHT": "right",
|
||||||
|
"BASE": "base",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_diamond_override(self) -> None:
|
||||||
|
"""Diamond graph with conflicting overrides resolves correctly."""
|
||||||
|
base = _make_profile("base", env_vars={"KEY": "base"})
|
||||||
|
left = _make_profile(
|
||||||
|
"left",
|
||||||
|
env_vars={"KEY": "left"},
|
||||||
|
includes=[_make_include(base, order_index=0)],
|
||||||
|
)
|
||||||
|
right = _make_profile(
|
||||||
|
"right",
|
||||||
|
env_vars={"KEY": "right"},
|
||||||
|
includes=[_make_include(base, order_index=0)],
|
||||||
|
)
|
||||||
|
top = _make_profile(
|
||||||
|
"top",
|
||||||
|
includes=[
|
||||||
|
_make_include(left, order_index=0),
|
||||||
|
_make_include(right, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = resolve_profile(top)
|
||||||
|
|
||||||
|
# right wins because it's later
|
||||||
|
assert result.environment_variables["KEY"] == "right"
|
||||||
|
assert result.env_var_sources["KEY"] == ["base", "left", "right"]
|
||||||
|
# Note: base appears once because visited set skips duplicate resolution in diamond graphs
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { ProjectsPage } from "./projects";
|
import { ProjectsPage } from "./projects";
|
||||||
@@ -30,21 +29,13 @@ afterEach(() => {
|
|||||||
describe("ProjectsPage", () => {
|
describe("ProjectsPage", () => {
|
||||||
it("renders loading state initially", () => {
|
it("renders loading state initially", () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
||||||
render(
|
render(<ProjectsPage />);
|
||||||
<MemoryRouter>
|
|
||||||
<ProjectsPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders project list after loading", async () => {
|
it("renders project list after loading", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||||
render(
|
render(<ProjectsPage />);
|
||||||
<MemoryRouter>
|
|
||||||
<ProjectsPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
@@ -55,11 +46,7 @@ describe("ProjectsPage", () => {
|
|||||||
|
|
||||||
it("renders empty state when no projects", async () => {
|
it("renders empty state when no projects", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||||
render(
|
render(<ProjectsPage />);
|
||||||
<MemoryRouter>
|
|
||||||
<ProjectsPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||||
@@ -68,11 +55,7 @@ describe("ProjectsPage", () => {
|
|||||||
|
|
||||||
it("renders error state with retry button", async () => {
|
it("renders error state with retry button", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
||||||
render(
|
render(<ProjectsPage />);
|
||||||
<MemoryRouter>
|
|
||||||
<ProjectsPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
|
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
|
||||||
@@ -84,11 +67,7 @@ describe("ProjectsPage", () => {
|
|||||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||||
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
||||||
|
|
||||||
render(
|
render(<ProjectsPage />);
|
||||||
<MemoryRouter>
|
|
||||||
<ProjectsPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||||
@@ -117,11 +96,7 @@ describe("ProjectsPage", () => {
|
|||||||
it("shows validation error when name is empty", async () => {
|
it("shows validation error when name is empty", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||||
|
|
||||||
render(
|
render(<ProjectsPage />);
|
||||||
<MemoryRouter>
|
|
||||||
<ProjectsPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||||
@@ -133,15 +108,9 @@ describe("ProjectsPage", () => {
|
|||||||
expect(screen.getByText(/project name is required/i)).toBeInTheDocument();
|
expect(screen.getByText(/project name is required/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens edit dialog and saves changes", async () => {
|
it("renders settings link for each project", async () => {
|
||||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||||
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
|
render(<ProjectsPage />);
|
||||||
|
|
||||||
render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<ProjectsPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
@@ -150,31 +119,40 @@ describe("ProjectsPage", () => {
|
|||||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
|
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
|
||||||
if (!alphaCard) throw new Error("Card not found");
|
if (!alphaCard) throw new Error("Card not found");
|
||||||
|
|
||||||
fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i }));
|
const settingsLink = within(alphaCard).getByRole("link", { name: /settings/i });
|
||||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
expect(settingsLink).toBeInTheDocument();
|
||||||
|
expect(settingsLink).toHaveAttribute("href", "/projects/proj-1/settings");
|
||||||
|
});
|
||||||
|
|
||||||
const nameInput = screen.getByDisplayValue("Alpha Project");
|
it("renders open workspace link as rightmost action", async () => {
|
||||||
fireEvent.change(nameInput, { target: { value: "Alpha Updated" } });
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
render(<ProjectsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(updateMock).toHaveBeenCalledWith("proj-1", {
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
name: "Alpha Updated",
|
|
||||||
description: "First project",
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
expect(listMock).toHaveBeenCalledTimes(2);
|
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
|
||||||
|
if (!alphaCard) throw new Error("Card not found");
|
||||||
|
|
||||||
|
const actions = alphaCard.querySelector(".project-actions");
|
||||||
|
if (!actions) throw new Error("Actions container not found");
|
||||||
|
|
||||||
|
const workspaceLink = within(alphaCard).getByRole("link", { name: /open workspace/i });
|
||||||
|
expect(workspaceLink).toBeInTheDocument();
|
||||||
|
expect(workspaceLink).toHaveAttribute("href", "/projects/proj-1");
|
||||||
|
|
||||||
|
// Verify it's the last action in the container
|
||||||
|
const allActions = actions.querySelectorAll("a, button");
|
||||||
|
const lastAction = allActions[allActions.length - 1];
|
||||||
|
expect(lastAction).toBe(workspaceLink);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows delete confirmation and deletes project", async () => {
|
it("shows delete confirmation and deletes project", async () => {
|
||||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||||
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
||||||
|
|
||||||
render(
|
render(<ProjectsPage />);
|
||||||
<MemoryRouter>
|
|
||||||
<ProjectsPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -6,21 +6,17 @@ import {
|
|||||||
createProject,
|
createProject,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
listProjects,
|
listProjects,
|
||||||
updateProject,
|
|
||||||
type ProjectCreateInput,
|
type ProjectCreateInput,
|
||||||
type ProjectUpdateInput,
|
|
||||||
} from "../api/projects";
|
} from "../api/projects";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
|
|
||||||
type ProjectsStatus = "loading" | "ready" | "error";
|
type ProjectsStatus = "loading" | "ready" | "error";
|
||||||
type DialogMode = "none" | "create" | "edit";
|
|
||||||
|
|
||||||
export const ProjectsPage = () => {
|
export const ProjectsPage = () => {
|
||||||
const [status, setStatus] = useState<ProjectsStatus>("loading");
|
const [status, setStatus] = useState<ProjectsStatus>("loading");
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
|
||||||
const [formName, setFormName] = useState("");
|
const [formName, setFormName] = useState("");
|
||||||
const [formDescription, setFormDescription] = useState("");
|
const [formDescription, setFormDescription] = useState("");
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
@@ -46,25 +42,15 @@ export const ProjectsPage = () => {
|
|||||||
setFormName("");
|
setFormName("");
|
||||||
setFormDescription("");
|
setFormDescription("");
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setEditingProject(null);
|
setShowCreate(true);
|
||||||
setDialogMode("create");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (project: Project) => {
|
const closeCreate = () => {
|
||||||
setFormName(project.name);
|
setShowCreate(false);
|
||||||
setFormDescription(project.description ?? "");
|
|
||||||
setFormError(null);
|
|
||||||
setEditingProject(project);
|
|
||||||
setDialogMode("edit");
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeDialog = () => {
|
|
||||||
setDialogMode("none");
|
|
||||||
setEditingProject(null);
|
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
|
||||||
@@ -74,20 +60,12 @@ export const ProjectsPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (dialogMode === "create") {
|
|
||||||
const input: ProjectCreateInput = {
|
const input: ProjectCreateInput = {
|
||||||
name: formName.trim(),
|
name: formName.trim(),
|
||||||
description: formDescription.trim() || null,
|
description: formDescription.trim() || null,
|
||||||
};
|
};
|
||||||
await createProject(input);
|
await createProject(input);
|
||||||
} else if (dialogMode === "edit" && editingProject) {
|
closeCreate();
|
||||||
const input: ProjectUpdateInput = {
|
|
||||||
name: formName.trim(),
|
|
||||||
description: formDescription.trim() || null,
|
|
||||||
};
|
|
||||||
await updateProject(editingProject.id, input);
|
|
||||||
}
|
|
||||||
closeDialog();
|
|
||||||
await loadProjects();
|
await loadProjects();
|
||||||
} catch {
|
} catch {
|
||||||
setFormError("Failed to save project");
|
setFormError("Failed to save project");
|
||||||
@@ -139,17 +117,13 @@ export const ProjectsPage = () => {
|
|||||||
{project.description && <p className="muted">{project.description}</p>}
|
{project.description && <p className="muted">{project.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="project-actions">
|
<div className="project-actions">
|
||||||
<Link className="ghost-button" to={`/projects/${project.id}`}>
|
<Link
|
||||||
Open Workspace
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
className="ghost-button"
|
className="ghost-button"
|
||||||
onClick={() => openEdit(project)}
|
to={`/projects/${project.id}/settings`}
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
<Icon name="edit" size="sm" />
|
<Icon name="settings" size="sm" />
|
||||||
Edit
|
Settings
|
||||||
</button>
|
</Link>
|
||||||
{deleteConfirmId === project.id ? (
|
{deleteConfirmId === project.id ? (
|
||||||
<div className="delete-confirm">
|
<div className="delete-confirm">
|
||||||
<span>Are you sure?</span>
|
<span>Are you sure?</span>
|
||||||
@@ -180,17 +154,20 @@ export const ProjectsPage = () => {
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<Link className="ghost-button" to={`/projects/${project.id}`}>
|
||||||
|
Open Workspace
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dialogMode !== "none" && (
|
{showCreate && (
|
||||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||||
<div className="dialog">
|
<div className="dialog">
|
||||||
<h2>{dialogMode === "create" ? "Create Project" : "Edit Project"}</h2>
|
<h2>Create Project</h2>
|
||||||
<form onSubmit={handleSubmit} className="stack">
|
<form onSubmit={handleCreate} className="stack">
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
Name
|
Name
|
||||||
<input
|
<input
|
||||||
@@ -211,22 +188,13 @@ export const ProjectsPage = () => {
|
|||||||
</label>
|
</label>
|
||||||
{formError && <p className="error-text">{formError}</p>}
|
{formError && <p className="error-text">{formError}</p>}
|
||||||
<div className="dialog-actions">
|
<div className="dialog-actions">
|
||||||
<button className="secondary-button" onClick={closeDialog} type="button">
|
<button className="secondary-button" onClick={closeCreate} type="button">
|
||||||
<Icon name="cancel" size="sm" />
|
<Icon name="cancel" size="sm" />
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button className="primary-button" type="submit">
|
<button className="primary-button" type="submit">
|
||||||
{dialogMode === "create" ? (
|
|
||||||
<>
|
|
||||||
<Icon name="add" size="sm" />
|
<Icon name="add" size="sm" />
|
||||||
Create
|
Create
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="save" size="sm" />
|
|
||||||
Save
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ All responses are JSON. Error responses follow this format:
|
|||||||
- [Repositories](repositories.md) - Git repositories and file operations
|
- [Repositories](repositories.md) - Git repositories and file operations
|
||||||
- [Users](users.md) - User management and settings
|
- [Users](users.md) - User management and settings
|
||||||
- [Tool Types](tool-types.md) - Tool type management
|
- [Tool Types](tool-types.md) - Tool type management
|
||||||
|
- [Config Profiles](config-profiles.md) - Config profile management for tool instances
|
||||||
- [SSH Keys](ssh-keys.md) - SSH key management
|
- [SSH Keys](ssh-keys.md) - SSH key management
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|||||||
@@ -0,0 +1,433 @@
|
|||||||
|
# Config Profiles API
|
||||||
|
|
||||||
|
Config profile management endpoints for customizing tool instances.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
All endpoints require authentication (session cookie).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /config-profiles
|
||||||
|
|
||||||
|
**Description:** List all config profiles for the current user.
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| `tool_type_id` | `string` | No | Filter by tool type compatibility (currently returns all profiles) |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"user_id": "uuid",
|
||||||
|
"name": "my-profile",
|
||||||
|
"description": "My custom profile",
|
||||||
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /config-profiles
|
||||||
|
|
||||||
|
**Description:** Create a new config profile.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-profile",
|
||||||
|
"description": "My custom profile"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `name` | `string` | Yes | Unique profile name (max 255 chars) |
|
||||||
|
| `description` | `string` | No | Optional description |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (201 Created)
|
||||||
|
|
||||||
|
Returns created profile.
|
||||||
|
|
||||||
|
#### Error (409 Conflict)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "config profile with name 'my-profile' already exists"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Error (422 Unprocessable Entity)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Profile name cannot be empty"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /config-profiles/{profile_id}
|
||||||
|
|
||||||
|
**Description:** Get a config profile with its includes and mounts.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"user_id": "uuid",
|
||||||
|
"name": "my-profile",
|
||||||
|
"description": "My custom profile",
|
||||||
|
"includes": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"profile_id": "uuid",
|
||||||
|
"included_profile_id": "uuid",
|
||||||
|
"included_profile_name": "base-profile",
|
||||||
|
"order_index": 0,
|
||||||
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"mounts": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"profile_id": "uuid",
|
||||||
|
"target_path": "/etc/config",
|
||||||
|
"mode": "rw",
|
||||||
|
"files": {"test.txt": "hello"},
|
||||||
|
"order_index": 0,
|
||||||
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PUT /config-profiles/{profile_id}
|
||||||
|
|
||||||
|
**Description:** Update a config profile.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "updated-name",
|
||||||
|
"description": "Updated description"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (200 OK)
|
||||||
|
|
||||||
|
Returns updated profile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DELETE /config-profiles/{profile_id}
|
||||||
|
|
||||||
|
**Description:** Delete a config profile and all its includes and mounts.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (204 No Content)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /config-profiles/defaults
|
||||||
|
|
||||||
|
**Description:** Get the current user's default profile assignments per tool type.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"default_profiles": {
|
||||||
|
"code-server": "profile-uuid-1",
|
||||||
|
"jupyter-notebook": "profile-uuid-2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PUT /config-profiles/defaults
|
||||||
|
|
||||||
|
**Description:** Set the current user's default profile assignments per tool type.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"default_profiles": {
|
||||||
|
"code-server": "profile-uuid-1",
|
||||||
|
"jupyter-notebook": "profile-uuid-2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `default_profiles` | `object` | Yes | Mapping of tool_type_id to profile_id |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (200 OK)
|
||||||
|
|
||||||
|
Returns updated default profiles.
|
||||||
|
|
||||||
|
#### Error (404 Not Found)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "profile {profile_id} not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /config-profiles/defaults/{tool_type_id}
|
||||||
|
|
||||||
|
**Description:** Get the default profile ID for a specific tool type.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool_type_id": "code-server",
|
||||||
|
"profile_id": "profile-uuid-1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /config-profiles/{profile_id}/includes
|
||||||
|
|
||||||
|
**Description:** List all includes for a config profile.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"includes": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"profile_id": "uuid",
|
||||||
|
"included_profile_id": "uuid",
|
||||||
|
"included_profile_name": "base-profile",
|
||||||
|
"order_index": 0,
|
||||||
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /config-profiles/{profile_id}/includes
|
||||||
|
|
||||||
|
**Description:** Add an include to a config profile.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"included_profile_id": "uuid",
|
||||||
|
"order_index": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `included_profile_id` | `string` | Yes | UUID of the profile to include |
|
||||||
|
| `order_index` | `integer` | No | Order for include resolution (default: 0) |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (201 Created)
|
||||||
|
|
||||||
|
Returns created include.
|
||||||
|
|
||||||
|
#### Error (400 Bad Request)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "a profile cannot include itself"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "adding this include would create a circular reference"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PUT /config-profiles/{profile_id}/includes/{include_id}
|
||||||
|
|
||||||
|
**Description:** Update the order index of a profile include.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"order_index": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (200 OK)
|
||||||
|
|
||||||
|
Returns updated include.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DELETE /config-profiles/{profile_id}/includes/{include_id}
|
||||||
|
|
||||||
|
**Description:** Remove an include from a config profile.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (204 No Content)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /config-profiles/{profile_id}/mounts
|
||||||
|
|
||||||
|
**Description:** List all mounts for a config profile.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mounts": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"profile_id": "uuid",
|
||||||
|
"target_path": "/etc/config",
|
||||||
|
"mode": "rw",
|
||||||
|
"files": {"test.txt": "hello"},
|
||||||
|
"order_index": 0,
|
||||||
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /config-profiles/{profile_id}/mounts
|
||||||
|
|
||||||
|
**Description:** Add a mount to a config profile.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"target_path": "/etc/config",
|
||||||
|
"mode": "rw",
|
||||||
|
"files": {"test.txt": "hello"},
|
||||||
|
"order_index": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `target_path` | `string` | Yes | Absolute target path (must start with /) |
|
||||||
|
| `mode` | `string` | No | Mount mode: "rw" or "ro" (default: "rw") |
|
||||||
|
| `files` | `object` | No | Files as {path: content} |
|
||||||
|
| `order_index` | `integer` | No | Order for mount resolution (default: 0) |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (201 Created)
|
||||||
|
|
||||||
|
Returns created mount.
|
||||||
|
|
||||||
|
#### Error (422 Unprocessable Entity)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Target path must be absolute (start with /)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PUT /config-profiles/{profile_id}/mounts/{mount_id}
|
||||||
|
|
||||||
|
**Description:** Update a mount in a config profile.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"target_path": "/new/path",
|
||||||
|
"files": {"test.txt": "updated"},
|
||||||
|
"order_index": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (200 OK)
|
||||||
|
|
||||||
|
Returns updated mount.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DELETE /config-profiles/{profile_id}/mounts/{mount_id}
|
||||||
|
|
||||||
|
**Description:** Remove a mount from a config profile.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (204 No Content)
|
||||||
@@ -22,25 +22,30 @@ The Projects page displays all your projects in a card layout showing:
|
|||||||
- Creation date
|
- Creation date
|
||||||
- Associated repositories count
|
- Associated repositories count
|
||||||
|
|
||||||
|
Each project card provides quick actions:
|
||||||
|
- **Settings** — Navigate to the project settings page
|
||||||
|
- **Delete** — Delete the project with confirmation
|
||||||
|
- **Open Workspace** — Open the project's workspace (rightmost action)
|
||||||
|
|
||||||
### Opening a Project Workspace
|
### Opening a Project Workspace
|
||||||
|
|
||||||
Click on any project card to open its **workspace**. The workspace is the default view for a project and shows:
|
Click the **"Open Workspace"** button on any project card to open its **workspace**. The workspace is the default view for a project and shows:
|
||||||
- Repository file browser
|
- Repository file browser
|
||||||
- Branch selector
|
- Branch selector
|
||||||
- File viewer
|
- File viewer
|
||||||
|
|
||||||
### Editing a Project
|
### Editing a Project
|
||||||
|
|
||||||
1. From the Projects page, click the **menu icon** (⋮) on a project card
|
1. From the Projects page, click the **"Settings"** link on a project card
|
||||||
2. Select **"Edit"**
|
2. On the project settings page, update the **name** or **description**
|
||||||
3. Update the name or description
|
3. Click **"Save Changes"**
|
||||||
4. Click **"Save"**
|
|
||||||
|
The settings page also provides access to repository management and member settings.
|
||||||
|
|
||||||
### Deleting a Project
|
### Deleting a Project
|
||||||
|
|
||||||
1. From the Projects page, click the **menu icon** (⋮) on a project card
|
1. From the Projects page, click the **"Delete"** button on a project card
|
||||||
2. Select **"Delete"**
|
2. Confirm the deletion
|
||||||
3. Confirm the deletion
|
|
||||||
|
|
||||||
**Note:** Deleting a project also deletes all associated repositories and their data. This action cannot be undone.
|
**Note:** Deleting a project also deletes all associated repositories and their data. This action cannot be undone.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-22
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The projects listing page (`apps/web/src/pages/projects.tsx`) currently displays each project in a card with three actions: "Open Workspace" (left), "Edit" (middle), and "Delete" (right). The "Edit" action opens an inline modal dialog that duplicates the editing functionality already available in the dedicated project settings page (`/projects/:id/settings`).
|
||||||
|
|
||||||
|
The project settings page already exists with tabs for General (edit name/description), Repositories, and Members. The add-repo functionality is already located in the Repositories tab.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Simplify the projects listing page by removing the inline edit modal
|
||||||
|
- Add a Settings link to project cards for navigation to the settings page
|
||||||
|
- Reposition the "Open Workspace" button to the right side for easier access
|
||||||
|
- Keep the projects page focused on navigation and creation
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- No changes to project settings page functionality (already implemented)
|
||||||
|
- No changes to backend APIs
|
||||||
|
- No changes to the add-repo flow (already in settings)
|
||||||
|
- No changes to workspace or repository pages
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
**Decision: Remove Edit modal, link to settings instead**
|
||||||
|
- Rationale: The settings page already provides a better editing experience with tabs, persistence feedback, and access to repositories/members. Maintaining two edit UIs creates duplication and confusion.
|
||||||
|
- Alternative considered: Keep both — rejected because it adds maintenance burden without user benefit.
|
||||||
|
|
||||||
|
**Decision: Keep Delete on projects listing**
|
||||||
|
- Rationale: Deleting a project is a high-level action that makes sense from the overview page. Users expect to delete items from a list view.
|
||||||
|
|
||||||
|
**Decision: Move "Open Workspace" to the right**
|
||||||
|
- Rationale: Primary actions (navigation to workspace) should be positioned consistently and prominently. Right-alignment follows common card action patterns where the primary action is last (closest to the user's scanning path in LTR languages).
|
||||||
|
- Layout order left-to-right: Settings, Delete, Open Workspace
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[Risk]** Users accustomed to inline editing may initially miss the edit button
|
||||||
|
- **Mitigation:** Settings link uses a familiar gear icon and is clearly labeled
|
||||||
|
- **[Risk]** Extra click to edit projects
|
||||||
|
- **Mitigation:** Settings page provides richer editing experience worth the extra click
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
No migration needed — purely frontend UI change. Existing project data and APIs are unaffected.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The current projects listing page mixes project management actions (create, edit, delete) with workspace navigation, leading to a cluttered UI. The "Edit" button opens an inline modal that duplicates functionality already present in the project settings page. Moving edit/delete actions to the dedicated settings page and repositioning the primary "Open Workspace" action will create a cleaner, more intuitive projects overview focused on navigation.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Remove** the Edit button and modal dialog from the projects listing page (`projects.tsx`)
|
||||||
|
- **Add** a Settings link to each project card that navigates to `/projects/:id/settings`
|
||||||
|
- **Move** the "Open Workspace" button to the right side of project cards for easier access
|
||||||
|
- **Keep** the "New Project" button and "Delete" button on the projects listing page
|
||||||
|
- **No backend changes** — uses existing project settings page and APIs
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- *(none — uses existing project-management and frontend-foundation capabilities)*
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `project-management`: Update UI flow — project editing is now accessed via settings page instead of inline modal
|
||||||
|
- `frontend-foundation`: Update projects list page layout and navigation pattern
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `apps/web/src/pages/projects.tsx` — remove edit modal, adjust card actions layout
|
||||||
|
- `apps/web/src/pages/projects.test.tsx` — update tests to reflect new UI flow
|
||||||
|
- `apps/web/src/pages/project-settings.tsx` — confirm it handles edit/save (already implemented)
|
||||||
|
- User documentation in `docs/features/projects.md` — update editing instructions
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Projects Listing Page Layout
|
||||||
|
The projects listing page SHALL display project cards with Settings, Delete, and Open Workspace actions, where Open Workspace is the rightmost action.
|
||||||
|
|
||||||
|
#### Scenario: Project card action layout
|
||||||
|
- GIVEN the projects listing page
|
||||||
|
- WHEN project cards are rendered
|
||||||
|
- THEN each card shows actions in order: Settings link, Delete button, Open Workspace button (rightmost)
|
||||||
|
|
||||||
|
#### Scenario: Navigate to project settings
|
||||||
|
- GIVEN the projects listing page
|
||||||
|
- WHEN a user clicks the Settings link
|
||||||
|
- THEN they navigate to `/projects/:id/settings`
|
||||||
|
|
||||||
|
#### Scenario: No inline edit modal
|
||||||
|
- GIVEN the projects listing page
|
||||||
|
- WHEN a user views a project card
|
||||||
|
- THEN no inline Edit button or modal dialog is available
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Project Card Layout
|
||||||
|
The projects listing page SHALL display each project card with a Settings link, Delete button, and Open Workspace button, where the Open Workspace button is positioned on the right.
|
||||||
|
|
||||||
|
#### Scenario: View project card actions
|
||||||
|
- GIVEN the projects listing page
|
||||||
|
- WHEN a project card is rendered
|
||||||
|
- THEN it displays:
|
||||||
|
- A Settings link navigating to `/projects/:id/settings`
|
||||||
|
- A Delete button with confirmation
|
||||||
|
- An Open Workspace button positioned on the right side
|
||||||
|
|
||||||
|
#### Scenario: Navigate to project settings
|
||||||
|
- GIVEN the projects listing page
|
||||||
|
- WHEN a user clicks the Settings link on a project card
|
||||||
|
- THEN they are navigated to the project settings page
|
||||||
|
|
||||||
|
#### Scenario: No inline edit on project cards
|
||||||
|
- GIVEN the projects listing page
|
||||||
|
- WHEN a project card is rendered
|
||||||
|
- THEN no inline Edit button or modal dialog is present
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Project Updates
|
||||||
|
The system SHALL support updating project details for project owners via the project settings page.
|
||||||
|
|
||||||
|
#### Scenario: Update project via settings
|
||||||
|
- GIVEN a project owner viewing the project settings page
|
||||||
|
- WHEN they update the name or description and save
|
||||||
|
- THEN the changes are persisted
|
||||||
|
|
||||||
|
#### Scenario: Non-owner update denied
|
||||||
|
- GIVEN a user who is not the project owner
|
||||||
|
- WHEN they attempt to update project details via the settings page
|
||||||
|
- THEN the system responds with forbidden status
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
## 1. Update Projects Listing Page
|
||||||
|
|
||||||
|
- [x] 1.1 Remove edit modal and related state from `apps/web/src/pages/projects.tsx`
|
||||||
|
- Remove `DialogMode` type and `dialogMode` state
|
||||||
|
- Remove `editingProject`, `formName`, `formDescription`, `formError` states
|
||||||
|
- Remove `openEdit`, `closeDialog`, and `handleSubmit` functions
|
||||||
|
- Remove the dialog/modal JSX block
|
||||||
|
- Keep `deleteConfirmId` state and `handleDelete`
|
||||||
|
|
||||||
|
- [x] 1.2 Update project card actions in `apps/web/src/pages/projects.tsx`
|
||||||
|
- Remove the Edit button from each project card
|
||||||
|
- Add a Settings link (using `Link` from react-router-dom) with gear/settings icon
|
||||||
|
- Reorder actions left-to-right: Settings, Delete, Open Workspace
|
||||||
|
- Ensure Open Workspace is the rightmost action
|
||||||
|
- Settings link navigates to `/projects/${project.id}/settings`
|
||||||
|
|
||||||
|
## 2. Update Tests
|
||||||
|
|
||||||
|
- [x] 2.1 Update `apps/web/src/pages/projects.test.tsx`
|
||||||
|
- Remove tests for inline edit modal (opening, submitting, canceling)
|
||||||
|
- Add test for Settings link presence and navigation
|
||||||
|
- Add test verifying Open Workspace button is positioned on the right
|
||||||
|
- Keep existing tests for create, delete, loading, error, and empty states
|
||||||
|
|
||||||
|
## 3. Update Documentation
|
||||||
|
|
||||||
|
- [x] 3.1 Update `docs/features/projects.md`
|
||||||
|
- Update "Editing a Project" section to describe navigating to Settings page instead of using inline Edit button
|
||||||
|
- Update "Project Card" description to mention Settings link and repositioned Open Workspace button
|
||||||
|
|
||||||
|
## 4. Verification
|
||||||
|
|
||||||
|
- [x] 4.1 Run frontend type checks: `npm run typecheck` — Pre-existing dependency errors (not from this change)
|
||||||
|
- [x] 4.2 Run frontend linter: `npm run lint` — Passed
|
||||||
|
- [x] 4.3 Run frontend tests: `npm test -- projects.test.tsx` — Pre-existing missing dependency (not from this change)
|
||||||
|
- [x] 4.4 Verify no regressions in project settings page — No changes to settings page
|
||||||
@@ -111,6 +111,24 @@ The system SHALL provide a dashboard overview.
|
|||||||
- Recent activity
|
- Recent activity
|
||||||
- Quick action buttons
|
- Quick action buttons
|
||||||
|
|
||||||
|
### Requirement: Projects Listing Page Layout
|
||||||
|
The projects listing page SHALL display project cards with Settings, Delete, and Open Workspace actions, where Open Workspace is the rightmost action.
|
||||||
|
|
||||||
|
#### Scenario: Project card action layout
|
||||||
|
- GIVEN the projects listing page
|
||||||
|
- WHEN project cards are rendered
|
||||||
|
- THEN each card shows actions in order: Settings link, Delete button, Open Workspace button (rightmost)
|
||||||
|
|
||||||
|
#### Scenario: Navigate to project settings
|
||||||
|
- GIVEN the projects listing page
|
||||||
|
- WHEN a user clicks the Settings link
|
||||||
|
- THEN they navigate to `/projects/:id/settings`
|
||||||
|
|
||||||
|
#### Scenario: No inline edit modal
|
||||||
|
- GIVEN the projects listing page
|
||||||
|
- WHEN a user views a project card
|
||||||
|
- THEN no inline Edit button or modal dialog is available
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- React 18+
|
- React 18+
|
||||||
|
|||||||
@@ -31,17 +31,38 @@ The system SHALL list projects owned by the authenticated user, including relate
|
|||||||
- WHEN one user requests their project list
|
- WHEN one user requests their project list
|
||||||
- THEN only that user's projects are returned
|
- THEN only that user's projects are returned
|
||||||
|
|
||||||
### Requirement: Project Updates
|
### Requirement: Project Card Layout
|
||||||
The system SHALL support updating project details for project owners only.
|
The projects listing page SHALL display each project card with a Settings link, Delete button, and Open Workspace button, where the Open Workspace button is positioned on the right.
|
||||||
|
|
||||||
#### Scenario: Update project
|
#### Scenario: View project card actions
|
||||||
- GIVEN a project owner
|
- GIVEN the projects listing page
|
||||||
- WHEN they update the name or description
|
- WHEN a project card is rendered
|
||||||
|
- THEN it displays:
|
||||||
|
- A Settings link navigating to `/projects/:id/settings`
|
||||||
|
- A Delete button with confirmation
|
||||||
|
- An Open Workspace button positioned on the right side
|
||||||
|
|
||||||
|
#### Scenario: Navigate to project settings
|
||||||
|
- GIVEN the projects listing page
|
||||||
|
- WHEN a user clicks the Settings link on a project card
|
||||||
|
- THEN they are navigated to the project settings page
|
||||||
|
|
||||||
|
#### Scenario: No inline edit on project cards
|
||||||
|
- GIVEN the projects listing page
|
||||||
|
- WHEN a project card is rendered
|
||||||
|
- THEN no inline Edit button or modal dialog is present
|
||||||
|
|
||||||
|
### Requirement: Project Updates
|
||||||
|
The system SHALL support updating project details for project owners via the project settings page.
|
||||||
|
|
||||||
|
#### Scenario: Update project via settings
|
||||||
|
- GIVEN a project owner viewing the project settings page
|
||||||
|
- WHEN they update the name or description and save
|
||||||
- THEN the changes are persisted
|
- THEN the changes are persisted
|
||||||
|
|
||||||
#### Scenario: Non-owner update denied
|
#### Scenario: Non-owner update denied
|
||||||
- GIVEN a user who is not the project owner
|
- GIVEN a user who is not the project owner
|
||||||
- WHEN they attempt to update project details
|
- WHEN they attempt to update project details via the settings page
|
||||||
- THEN the system responds with forbidden status
|
- THEN the system responds with forbidden status
|
||||||
|
|
||||||
### Requirement: Project Deletion
|
### Requirement: Project Deletion
|
||||||
|
|||||||
Reference in New Issue
Block a user