Compare commits
9 Commits
| 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
|
||||
- **SSH Key Management** - Ed25519 key generation with secure storage
|
||||
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
|
||||
- **Config Profiles** - User-owned profile CRUD with includes, mounts, path validation, cycle detection, and default profile selection
|
||||
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -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.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.config_profile import ConfigProfile
|
||||
from src.models.tool_config import ToolConfig
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
@@ -41,6 +42,7 @@ from src.services.docker import (
|
||||
write_config_folder_files,
|
||||
)
|
||||
from src.services.docker_build import build_image
|
||||
from src.services.profile_resolver import resolve_profile
|
||||
from src.services.readiness_probe import execute_probe
|
||||
|
||||
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")
|
||||
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(
|
||||
@@ -107,6 +110,68 @@ def _modify_compose_file(
|
||||
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:
|
||||
"""Fetch a user by ID or raise 404 if not found."""
|
||||
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"
|
||||
)
|
||||
|
||||
# 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:
|
||||
# Generate unique name
|
||||
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
|
||||
@@ -262,6 +350,7 @@ services:
|
||||
status="pending",
|
||||
compose_path=compose_path,
|
||||
port=tool_port,
|
||||
selected_profile_id=selected_profile_id,
|
||||
)
|
||||
session.add(instance)
|
||||
await session.commit()
|
||||
@@ -273,6 +362,7 @@ services:
|
||||
"display_name": instance.display_name,
|
||||
"tool_type_id": str(instance.tool_type_id),
|
||||
"status": instance.status,
|
||||
"config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None,
|
||||
"created_at": instance.created_at.isoformat(),
|
||||
}
|
||||
except Exception as exc:
|
||||
@@ -335,6 +425,7 @@ async def list_instances(
|
||||
"status": i.status,
|
||||
"url": i.url,
|
||||
"port": i.port,
|
||||
"config_profile_id": str(i.selected_profile_id) if i.selected_profile_id else None,
|
||||
"created_at": i.created_at.isoformat(),
|
||||
})
|
||||
|
||||
@@ -395,6 +486,7 @@ async def get_instance(
|
||||
"compose_path": instance.compose_path,
|
||||
"url": instance.url,
|
||||
"port": instance.port,
|
||||
"config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None,
|
||||
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
|
||||
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
|
||||
"created_at": instance.created_at.isoformat(),
|
||||
@@ -452,6 +544,7 @@ async def start_instance(
|
||||
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,
|
||||
@@ -484,6 +577,31 @@ async def start_instance(
|
||||
# Merge 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
|
||||
folder_query = select(ConfigFolder).where(
|
||||
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)
|
||||
|
||||
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(
|
||||
instance.compose_path, "restart"
|
||||
instance.compose_path, "restart", env_file=env_file_path
|
||||
)
|
||||
|
||||
if returncode == 0:
|
||||
|
||||
@@ -2,7 +2,7 @@ import hmac
|
||||
import hashlib
|
||||
import json
|
||||
import base64
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
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."""
|
||||
payload = {
|
||||
"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())
|
||||
@@ -65,7 +65,7 @@ def decode_session_cookie(*, settings: Settings, cookie_value: str) -> dict[str,
|
||||
payload = json.loads(payload_bytes)
|
||||
|
||||
# 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")
|
||||
|
||||
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.instance_proxy import router as instance_proxy_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_instances import router as tool_instances_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(tool_types_router)
|
||||
app.include_router(config_folders_router)
|
||||
app.include_router(config_profiles_router)
|
||||
app.include_router(tool_instances_router)
|
||||
app.include_router(tool_configs_router)
|
||||
app.include_router(sessions_router)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from src.models.base import Base
|
||||
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.project import Project
|
||||
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_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(
|
||||
JSON, default=dict, nullable=True
|
||||
) # {"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)
|
||||
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.config_profile import ConfigProfile
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.tool_type import ToolType
|
||||
@@ -62,8 +63,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
||||
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()
|
||||
repository: Mapped["GitRepository"] = relationship()
|
||||
project: Mapped["Project"] = 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)
|
||||
|
||||
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
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
@@ -58,7 +58,7 @@ def _mint_token(user_id: str) -> str:
|
||||
subject=user_id,
|
||||
email="test@headquarter.local",
|
||||
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
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
@@ -59,7 +59,7 @@ def _mint_token(user_id: str) -> str:
|
||||
subject=user_id,
|
||||
email="test@headquarter.local",
|
||||
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
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import asyncio
|
||||
import io
|
||||
|
||||
@@ -83,7 +83,7 @@ def _create_auth_cookie(user_id: str) -> str:
|
||||
subject=user_id,
|
||||
email="test@headquarter.local",
|
||||
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.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 { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProjectsPage } from "./projects";
|
||||
@@ -30,21 +29,13 @@ afterEach(() => {
|
||||
describe("ProjectsPage", () => {
|
||||
it("renders loading state initially", () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders project list after loading", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
@@ -55,11 +46,7 @@ describe("ProjectsPage", () => {
|
||||
|
||||
it("renders empty state when no projects", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
@@ -68,11 +55,7 @@ describe("ProjectsPage", () => {
|
||||
|
||||
it("renders error state with retry button", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
|
||||
@@ -84,11 +67,7 @@ describe("ProjectsPage", () => {
|
||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
@@ -117,11 +96,7 @@ describe("ProjectsPage", () => {
|
||||
it("shows validation error when name is empty", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
@@ -133,15 +108,9 @@ describe("ProjectsPage", () => {
|
||||
expect(screen.getByText(/project name is required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens edit dialog and saves changes", async () => {
|
||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
it("renders settings link for each project", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
@@ -150,31 +119,40 @@ describe("ProjectsPage", () => {
|
||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
|
||||
if (!alphaCard) throw new Error("Card not found");
|
||||
|
||||
fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i }));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
const settingsLink = within(alphaCard).getByRole("link", { name: /settings/i });
|
||||
expect(settingsLink).toBeInTheDocument();
|
||||
expect(settingsLink).toHaveAttribute("href", "/projects/proj-1/settings");
|
||||
});
|
||||
|
||||
const nameInput = screen.getByDisplayValue("Alpha Project");
|
||||
fireEvent.change(nameInput, { target: { value: "Alpha Updated" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
it("renders open workspace link as rightmost action", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateMock).toHaveBeenCalledWith("proj-1", {
|
||||
name: "Alpha Updated",
|
||||
description: "First project",
|
||||
});
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
});
|
||||
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 () => {
|
||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
|
||||
@@ -6,21 +6,17 @@ import {
|
||||
createProject,
|
||||
deleteProject,
|
||||
listProjects,
|
||||
updateProject,
|
||||
type ProjectCreateInput,
|
||||
type ProjectUpdateInput,
|
||||
} from "../api/projects";
|
||||
import { Icon } from "../components/icon";
|
||||
import type { Project } from "../types";
|
||||
|
||||
type ProjectsStatus = "loading" | "ready" | "error";
|
||||
type DialogMode = "none" | "create" | "edit";
|
||||
|
||||
export const ProjectsPage = () => {
|
||||
const [status, setStatus] = useState<ProjectsStatus>("loading");
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formDescription, setFormDescription] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
@@ -46,25 +42,15 @@ export const ProjectsPage = () => {
|
||||
setFormName("");
|
||||
setFormDescription("");
|
||||
setFormError(null);
|
||||
setEditingProject(null);
|
||||
setDialogMode("create");
|
||||
setShowCreate(true);
|
||||
};
|
||||
|
||||
const openEdit = (project: Project) => {
|
||||
setFormName(project.name);
|
||||
setFormDescription(project.description ?? "");
|
||||
setFormError(null);
|
||||
setEditingProject(project);
|
||||
setDialogMode("edit");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogMode("none");
|
||||
setEditingProject(null);
|
||||
const closeCreate = () => {
|
||||
setShowCreate(false);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
@@ -74,20 +60,12 @@ export const ProjectsPage = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
if (dialogMode === "create") {
|
||||
const input: ProjectCreateInput = {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
};
|
||||
await createProject(input);
|
||||
} else if (dialogMode === "edit" && editingProject) {
|
||||
const input: ProjectUpdateInput = {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
};
|
||||
await updateProject(editingProject.id, input);
|
||||
}
|
||||
closeDialog();
|
||||
const input: ProjectCreateInput = {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
};
|
||||
await createProject(input);
|
||||
closeCreate();
|
||||
await loadProjects();
|
||||
} catch {
|
||||
setFormError("Failed to save project");
|
||||
@@ -139,17 +117,13 @@ export const ProjectsPage = () => {
|
||||
{project.description && <p className="muted">{project.description}</p>}
|
||||
</div>
|
||||
<div className="project-actions">
|
||||
<Link className="ghost-button" to={`/projects/${project.id}`}>
|
||||
Open Workspace
|
||||
</Link>
|
||||
<button
|
||||
<Link
|
||||
className="ghost-button"
|
||||
onClick={() => openEdit(project)}
|
||||
type="button"
|
||||
to={`/projects/${project.id}/settings`}
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
Edit
|
||||
</button>
|
||||
<Icon name="settings" size="sm" />
|
||||
Settings
|
||||
</Link>
|
||||
{deleteConfirmId === project.id ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Are you sure?</span>
|
||||
@@ -180,17 +154,20 @@ export const ProjectsPage = () => {
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<Link className="ghost-button" to={`/projects/${project.id}`}>
|
||||
Open Workspace
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialogMode !== "none" && (
|
||||
{showCreate && (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2>{dialogMode === "create" ? "Create Project" : "Edit Project"}</h2>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<h2>Create Project</h2>
|
||||
<form onSubmit={handleCreate} className="stack">
|
||||
<label className="form-field">
|
||||
Name
|
||||
<input
|
||||
@@ -211,22 +188,13 @@ export const ProjectsPage = () => {
|
||||
</label>
|
||||
{formError && <p className="error-text">{formError}</p>}
|
||||
<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" />
|
||||
Cancel
|
||||
</button>
|
||||
<button className="primary-button" type="submit">
|
||||
{dialogMode === "create" ? (
|
||||
<>
|
||||
<Icon name="add" size="sm" />
|
||||
Create
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="save" size="sm" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
<Icon name="add" size="sm" />
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -35,6 +35,7 @@ All responses are JSON. Error responses follow this format:
|
||||
- [Repositories](repositories.md) - Git repositories and file operations
|
||||
- [Users](users.md) - User management and settings
|
||||
- [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
|
||||
|
||||
## 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
|
||||
- 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
|
||||
|
||||
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
|
||||
- Branch selector
|
||||
- File viewer
|
||||
|
||||
### Editing a Project
|
||||
|
||||
1. From the Projects page, click the **menu icon** (⋮) on a project card
|
||||
2. Select **"Edit"**
|
||||
3. Update the name or description
|
||||
4. Click **"Save"**
|
||||
1. From the Projects page, click the **"Settings"** link on a project card
|
||||
2. On the project settings page, update the **name** or **description**
|
||||
3. Click **"Save Changes"**
|
||||
|
||||
The settings page also provides access to repository management and member settings.
|
||||
|
||||
### Deleting a Project
|
||||
|
||||
1. From the Projects page, click the **menu icon** (⋮) on a project card
|
||||
2. Select **"Delete"**
|
||||
3. Confirm the deletion
|
||||
1. From the Projects page, click the **"Delete"** button on a project card
|
||||
2. Confirm the deletion
|
||||
|
||||
**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
|
||||
- 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
|
||||
|
||||
- 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
|
||||
- THEN only that user's projects are returned
|
||||
|
||||
### Requirement: Project Updates
|
||||
The system SHALL support updating project details for project owners only.
|
||||
### 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: Update project
|
||||
- GIVEN a project owner
|
||||
- WHEN they update the name or description
|
||||
#### 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
|
||||
|
||||
### 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
|
||||
- WHEN they attempt to update project details via the settings page
|
||||
- THEN the system responds with forbidden status
|
||||
|
||||
### Requirement: Project Deletion
|
||||
|
||||
Reference in New Issue
Block a user