Merge feat/home-path-expansion into dev
This commit is contained in:
@@ -58,7 +58,9 @@ def _calculate_profile_size(data: dict) -> int:
|
|||||||
|
|
||||||
|
|
||||||
class GitMountMapping(BaseModel):
|
class GitMountMapping(BaseModel):
|
||||||
source_path: str = Field(description="Path within repository (supports glob patterns)")
|
source_path: str = Field(
|
||||||
|
description="Path within repository (supports glob patterns)"
|
||||||
|
)
|
||||||
target_path: str = Field(description="Absolute path inside container")
|
target_path: str = Field(description="Absolute path inside container")
|
||||||
|
|
||||||
@field_validator("source_path")
|
@field_validator("source_path")
|
||||||
@@ -80,16 +82,25 @@ class GitMountMapping(BaseModel):
|
|||||||
|
|
||||||
class GitMountItem(BaseModel):
|
class GitMountItem(BaseModel):
|
||||||
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
|
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
|
||||||
source_path: str | None = Field(default=None, description="Path within repository (legacy single mapping)")
|
source_path: str | None = Field(
|
||||||
target_path: str | None = Field(default=None, description="Absolute path inside container (legacy single mapping)")
|
default=None, description="Path within repository (legacy single mapping)"
|
||||||
|
)
|
||||||
|
target_path: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Absolute path inside container (legacy single mapping)",
|
||||||
|
)
|
||||||
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
||||||
mappings: list[GitMountMapping] | None = Field(default=None, description="Multiple source/target mappings from the same repo")
|
mappings: list[GitMountMapping] | None = Field(
|
||||||
|
default=None, description="Multiple source/target mappings from the same repo"
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("remote_url")
|
@field_validator("remote_url")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_remote_url(cls, v: str) -> str:
|
def validate_remote_url(cls, v: str) -> str:
|
||||||
if not v.startswith(("http://", "https://", "git@", "ssh://")):
|
if not v.startswith(("http://", "https://", "git@", "ssh://")):
|
||||||
raise ValueError("remote_url must be a valid git URL (https://, git@, or ssh://)")
|
raise ValueError(
|
||||||
|
"remote_url must be a valid git URL (https://, git@, or ssh://)"
|
||||||
|
)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("source_path")
|
@field_validator("source_path")
|
||||||
@@ -126,7 +137,9 @@ class GitMountItem(BaseModel):
|
|||||||
class MountItem(BaseModel):
|
class MountItem(BaseModel):
|
||||||
target: str = Field(description="Absolute mount target path")
|
target: str = Field(description="Absolute mount target path")
|
||||||
mode: str = Field(default="rw", description="Mount mode: ro or rw")
|
mode: str = Field(default="rw", description="Mount mode: ro or rw")
|
||||||
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
|
files: dict = Field(
|
||||||
|
default_factory=dict, description="Files as {relative_path: content}"
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("target")
|
@field_validator("target")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -163,10 +176,18 @@ class ConfigProfileCreate(BaseModel):
|
|||||||
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
||||||
env_vars: dict = Field(default_factory=dict, description="Environment variables")
|
env_vars: dict = Field(default_factory=dict, description="Environment variables")
|
||||||
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
|
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
|
||||||
mounts: list[MountItem] = Field(default_factory=list, description="Mount definitions")
|
mounts: list[MountItem] = Field(
|
||||||
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
|
default_factory=list, description="Mount definitions"
|
||||||
git_mounts: list[GitMountItem] = Field(default_factory=list, description="Git repository mounts")
|
)
|
||||||
is_default: bool = Field(default=False, description="Whether this is the default profile for its scope")
|
files: dict = Field(
|
||||||
|
default_factory=dict, description="Files as {relative_path: content}"
|
||||||
|
)
|
||||||
|
git_mounts: list[GitMountItem] = Field(
|
||||||
|
default_factory=list, description="Git repository mounts"
|
||||||
|
)
|
||||||
|
is_default: bool = Field(
|
||||||
|
default=False, description="Whether this is the default profile for its scope"
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("project_id", "tool_type_id")
|
@field_validator("project_id", "tool_type_id")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -216,10 +237,18 @@ class ConfigProfileUpdate(BaseModel):
|
|||||||
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
||||||
env_vars: dict | None = Field(default=None, description="Environment variables")
|
env_vars: dict | None = Field(default=None, description="Environment variables")
|
||||||
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
|
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
|
||||||
mounts: list[MountItem] | None = Field(default=None, description="Mount definitions")
|
mounts: list[MountItem] | None = Field(
|
||||||
files: dict | None = Field(default=None, description="Files as {relative_path: content}")
|
default=None, description="Mount definitions"
|
||||||
git_mounts: list[GitMountItem] | None = Field(default=None, description="Git repository mounts")
|
)
|
||||||
is_default: bool | None = Field(default=None, description="Whether this is the default profile")
|
files: dict | None = Field(
|
||||||
|
default=None, description="Files as {relative_path: content}"
|
||||||
|
)
|
||||||
|
git_mounts: list[GitMountItem] | None = Field(
|
||||||
|
default=None, description="Git repository mounts"
|
||||||
|
)
|
||||||
|
is_default: bool | None = Field(
|
||||||
|
default=None, description="Whether this is the default profile"
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("project_id", "tool_type_id")
|
@field_validator("project_id", "tool_type_id")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -269,7 +298,9 @@ class ConfigProfileResponse(BaseModel):
|
|||||||
updated_at: str
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
async def _get_profile_with_includes(session: AsyncSession, profile_id: uuid.UUID) -> ConfigProfile | None:
|
async def _get_profile_with_includes(
|
||||||
|
session: AsyncSession, profile_id: uuid.UUID
|
||||||
|
) -> ConfigProfile | None:
|
||||||
"""Fetch a profile with includes eagerly loaded."""
|
"""Fetch a profile with includes eagerly loaded."""
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ConfigProfile)
|
select(ConfigProfile)
|
||||||
@@ -289,12 +320,16 @@ async def _check_access(
|
|||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
project = await session.get(Project, project_id)
|
project = await session.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
||||||
|
)
|
||||||
# Add ownership check if needed; for now just verify existence
|
# Add ownership check if needed; for now just verify existence
|
||||||
if tool_type_id is not None:
|
if tool_type_id is not None:
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _validate_git_mounts(
|
async def _validate_git_mounts(
|
||||||
@@ -304,7 +339,7 @@ async def _validate_git_mounts(
|
|||||||
project_id: uuid.UUID | None = None,
|
project_id: uuid.UUID | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Validate git mount URLs.
|
"""Validate git mount URLs.
|
||||||
|
|
||||||
Simply checks that remote_url looks like a valid git URL.
|
Simply checks that remote_url looks like a valid git URL.
|
||||||
Actual clone validation happens at instance startup time.
|
Actual clone validation happens at instance startup time.
|
||||||
"""
|
"""
|
||||||
@@ -315,7 +350,7 @@ async def _validate_git_mounts(
|
|||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Git mount missing remote_url",
|
detail="Git mount missing remote_url",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
|
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
@@ -323,7 +358,9 @@ async def _validate_git_mounts(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None) -> dict:
|
def _profile_to_response(
|
||||||
|
profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None
|
||||||
|
) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(profile.id),
|
"id": str(profile.id),
|
||||||
"user_id": str(profile.user_id),
|
"user_id": str(profile.user_id),
|
||||||
@@ -353,13 +390,19 @@ def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInc
|
|||||||
@router.get("", response_model=list[ConfigProfileResponse])
|
@router.get("", response_model=list[ConfigProfileResponse])
|
||||||
async def list_config_profiles(
|
async def list_config_profiles(
|
||||||
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
||||||
tool_type_id: str | None = Query(None, description="Filter by tool type compatibility"),
|
tool_type_id: str | None = Query(
|
||||||
|
None, description="Filter by tool type compatibility"
|
||||||
|
),
|
||||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""List config profiles, optionally filtered by compatibility."""
|
"""List config profiles, optionally filtered by compatibility."""
|
||||||
user_uuid = current_user_id
|
user_uuid = current_user_id
|
||||||
query = select(ConfigProfile).where(ConfigProfile.user_id == user_uuid).options(selectinload(ConfigProfile.includes))
|
query = (
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.user_id == user_uuid)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
|
||||||
if project_id or tool_type_id:
|
if project_id or tool_type_id:
|
||||||
# Compatibility filter: include portable profiles and matching scoped profiles
|
# Compatibility filter: include portable profiles and matching scoped profiles
|
||||||
@@ -371,7 +414,8 @@ async def list_config_profiles(
|
|||||||
conditions: list = []
|
conditions: list = []
|
||||||
# Portable profiles (no project, no tool)
|
# Portable profiles (no project, no tool)
|
||||||
conditions.append(
|
conditions.append(
|
||||||
(ConfigProfile.project_id.is_(None)) & (ConfigProfile.tool_type_id.is_(None))
|
(ConfigProfile.project_id.is_(None))
|
||||||
|
& (ConfigProfile.tool_type_id.is_(None))
|
||||||
)
|
)
|
||||||
if project_uuid:
|
if project_uuid:
|
||||||
# Profiles matching this project (with or without tool)
|
# Profiles matching this project (with or without tool)
|
||||||
@@ -382,7 +426,8 @@ async def list_config_profiles(
|
|||||||
if project_uuid and tool_uuid:
|
if project_uuid and tool_uuid:
|
||||||
# Exact match
|
# Exact match
|
||||||
conditions.append(
|
conditions.append(
|
||||||
(ConfigProfile.project_id == project_uuid) & (ConfigProfile.tool_type_id == tool_uuid)
|
(ConfigProfile.project_id == project_uuid)
|
||||||
|
& (ConfigProfile.tool_type_id == tool_uuid)
|
||||||
)
|
)
|
||||||
|
|
||||||
query = query.where(or_(*conditions))
|
query = query.where(or_(*conditions))
|
||||||
@@ -392,7 +437,9 @@ async def list_config_profiles(
|
|||||||
return [_profile_to_response(p) for p in profiles]
|
return [_profile_to_response(p) for p in profiles]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED)
|
@router.post(
|
||||||
|
"", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
async def create_config_profile(
|
async def create_config_profile(
|
||||||
data: ConfigProfileCreate,
|
data: ConfigProfileCreate,
|
||||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
@@ -403,10 +450,12 @@ async def create_config_profile(
|
|||||||
|
|
||||||
# Check for duplicate name
|
# Check for duplicate name
|
||||||
existing = await session.execute(
|
existing = await session.execute(
|
||||||
select(ConfigProfile).where(
|
select(ConfigProfile)
|
||||||
|
.where(
|
||||||
ConfigProfile.user_id == user_uuid,
|
ConfigProfile.user_id == user_uuid,
|
||||||
ConfigProfile.name == data.name,
|
ConfigProfile.name == data.name,
|
||||||
).options(selectinload(ConfigProfile.includes))
|
)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
)
|
)
|
||||||
if existing.scalar_one_or_none() is not None:
|
if existing.scalar_one_or_none() is not None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -418,10 +467,12 @@ async def create_config_profile(
|
|||||||
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
|
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
|
||||||
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
|
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
|
||||||
await _check_access(session, user_uuid, project_uuid, tool_uuid)
|
await _check_access(session, user_uuid, project_uuid, tool_uuid)
|
||||||
|
|
||||||
# Validate git mounts reference existing repositories
|
# Validate git mounts reference existing repositories
|
||||||
if data.git_mounts:
|
if data.git_mounts:
|
||||||
git_mounts_data = [m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts]
|
git_mounts_data = [
|
||||||
|
m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts
|
||||||
|
]
|
||||||
await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
|
await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
|
||||||
|
|
||||||
# Check size
|
# Check size
|
||||||
@@ -469,9 +520,13 @@ async def get_config_profile(
|
|||||||
"""Get a config profile by ID."""
|
"""Get a config profile by ID."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
if profile.user_id != current_user_id:
|
if profile.user_id != current_user_id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
return _profile_to_response(profile)
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@@ -485,9 +540,13 @@ async def update_config_profile(
|
|||||||
"""Update a config profile."""
|
"""Update a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
if profile.user_id != current_user_id:
|
if profile.user_id != current_user_id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
@@ -518,14 +577,16 @@ async def update_config_profile(
|
|||||||
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
|
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
|
||||||
)
|
)
|
||||||
await _check_access(session, profile.user_id, project_uuid, tool_uuid)
|
await _check_access(session, profile.user_id, project_uuid, tool_uuid)
|
||||||
|
|
||||||
# Validate git mounts reference existing repositories
|
# Validate git mounts reference existing repositories
|
||||||
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
|
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
|
||||||
git_mounts_data = [
|
git_mounts_data = [
|
||||||
m.model_dump() if hasattr(m, "model_dump") else m
|
m.model_dump() if hasattr(m, "model_dump") else m
|
||||||
for m in update_data["git_mounts"]
|
for m in update_data["git_mounts"]
|
||||||
]
|
]
|
||||||
await _validate_git_mounts(session, profile.user_id, git_mounts_data, project_uuid)
|
await _validate_git_mounts(
|
||||||
|
session, profile.user_id, git_mounts_data, project_uuid
|
||||||
|
)
|
||||||
|
|
||||||
# Check size
|
# Check size
|
||||||
current_data = _profile_to_response(profile)
|
current_data = _profile_to_response(profile)
|
||||||
@@ -570,9 +631,13 @@ async def delete_config_profile(
|
|||||||
"""Delete a config profile."""
|
"""Delete a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
if profile.user_id != current_user_id:
|
if profile.user_id != current_user_id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
await session.delete(profile)
|
await session.delete(profile)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -591,9 +656,13 @@ async def update_profile_includes(
|
|||||||
"""Update the ordered includes for a config profile."""
|
"""Update the ordered includes for a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
if profile.user_id != current_user_id:
|
if profile.user_id != current_user_id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
# Validate all included profiles exist and belong to the user
|
# Validate all included profiles exist and belong to the user
|
||||||
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
||||||
@@ -633,7 +702,9 @@ async def update_profile_includes(
|
|||||||
|
|
||||||
# Remove existing includes
|
# Remove existing includes
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
|
select(ConfigProfileInclude).where(
|
||||||
|
ConfigProfileInclude.profile_id == profile.id
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for existing in result.scalars().all():
|
for existing in result.scalars().all():
|
||||||
await session.delete(existing)
|
await session.delete(existing)
|
||||||
@@ -658,7 +729,9 @@ async def update_profile_includes(
|
|||||||
profile = result.scalar_one()
|
profile = result.scalar_one()
|
||||||
|
|
||||||
inc_result = await session.execute(
|
inc_result = await session.execute(
|
||||||
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
|
select(ConfigProfileInclude).where(
|
||||||
|
ConfigProfileInclude.profile_id == profile.id
|
||||||
|
)
|
||||||
)
|
)
|
||||||
direct_includes = inc_result.scalars().all()
|
direct_includes = inc_result.scalars().all()
|
||||||
|
|
||||||
@@ -675,9 +748,13 @@ async def preview_config_profile(
|
|||||||
"""Preview the resolved output of a config profile."""
|
"""Preview the resolved output of a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
if profile.user_id != current_user_id:
|
if profile.user_id != current_user_id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolved = await resolve_profile(session, profile.id)
|
resolved = await resolve_profile(session, profile.id)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from src.services.config_profile_resolver import (
|
|||||||
ConfigProfileCycleError,
|
ConfigProfileCycleError,
|
||||||
ResolvedProfile,
|
ResolvedProfile,
|
||||||
apply_resolved_profile,
|
apply_resolved_profile,
|
||||||
|
expand_container_path,
|
||||||
resolve_profile,
|
resolve_profile,
|
||||||
)
|
)
|
||||||
from src.services.docker import (
|
from src.services.docker import (
|
||||||
@@ -67,6 +68,7 @@ from src.services.manifest_compiler import (
|
|||||||
compile_entrypoint,
|
compile_entrypoint,
|
||||||
compute_image_tag,
|
compute_image_tag,
|
||||||
deep_merge,
|
deep_merge,
|
||||||
|
get_manifest_home_dir,
|
||||||
merge_with_config,
|
merge_with_config,
|
||||||
resolve_base,
|
resolve_base,
|
||||||
)
|
)
|
||||||
@@ -82,6 +84,7 @@ async def _resolve_git_mounts(
|
|||||||
resolved: ResolvedProfile,
|
resolved: ResolvedProfile,
|
||||||
instance_dir: str | None = None,
|
instance_dir: str | None = None,
|
||||||
working_directory: str | None = None,
|
working_directory: str | None = None,
|
||||||
|
home_dir: str = "/root",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Convert git mounts from resolved profile to Docker volume mounts.
|
"""Convert git mounts from resolved profile to Docker volume mounts.
|
||||||
|
|
||||||
@@ -97,7 +100,7 @@ async def _resolve_git_mounts(
|
|||||||
for git_mount in resolved.git_mounts:
|
for git_mount in resolved.git_mounts:
|
||||||
tasks.append(
|
tasks.append(
|
||||||
_resolve_single_git_mount(
|
_resolve_single_git_mount(
|
||||||
session, git_mount, instance_dir, working_directory
|
session, git_mount, instance_dir, working_directory, home_dir
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -184,6 +187,7 @@ def _resolve_git_mount_mappings(
|
|||||||
repo_path: str,
|
repo_path: str,
|
||||||
mappings: list[dict],
|
mappings: list[dict],
|
||||||
working_directory: str | None,
|
working_directory: str | None,
|
||||||
|
home_dir: str = "/root",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Resolve mappings from an already-cloned repo to volume mount entries.
|
"""Resolve mappings from an already-cloned repo to volume mount entries.
|
||||||
|
|
||||||
@@ -199,6 +203,9 @@ def _resolve_git_mount_mappings(
|
|||||||
logger.warning("Invalid mapping skipped: missing target_path")
|
logger.warning("Invalid mapping skipped: missing target_path")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Expand ~ and $HOME in target path
|
||||||
|
target_path = expand_container_path(target_path, home_dir)
|
||||||
|
|
||||||
# Resolve relative target paths against working directory
|
# Resolve relative target paths against working directory
|
||||||
final_target = target_path
|
final_target = target_path
|
||||||
if not target_path.startswith("/"):
|
if not target_path.startswith("/"):
|
||||||
@@ -261,6 +268,7 @@ async def _resolve_single_git_mount(
|
|||||||
git_mount: dict,
|
git_mount: dict,
|
||||||
instance_dir: str | None = None,
|
instance_dir: str | None = None,
|
||||||
working_directory: str | None = None,
|
working_directory: str | None = None,
|
||||||
|
home_dir: str = "/root",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Resolve a single git mount to volume mount entries.
|
"""Resolve a single git mount to volume mount entries.
|
||||||
|
|
||||||
@@ -293,7 +301,7 @@ async def _resolve_single_git_mount(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# Resolve all mappings from the cloned repo
|
# Resolve all mappings from the cloned repo
|
||||||
return _resolve_git_mount_mappings(repo_path, mappings, working_directory)
|
return _resolve_git_mount_mappings(repo_path, mappings, working_directory, home_dir)
|
||||||
|
|
||||||
|
|
||||||
def _checkout_branch(repo_path: str, branch: str) -> bool:
|
def _checkout_branch(repo_path: str, branch: str) -> bool:
|
||||||
@@ -545,6 +553,7 @@ def _modify_compose_file(
|
|||||||
start_command: str | None = None,
|
start_command: str | None = None,
|
||||||
working_directory: str | None = None,
|
working_directory: str | None = None,
|
||||||
extra_volumes: list[dict] | None = None,
|
extra_volumes: list[dict] | None = None,
|
||||||
|
home_dir: str = "/root",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Modify compose file with runtime overrides."""
|
"""Modify compose file with runtime overrides."""
|
||||||
import yaml
|
import yaml
|
||||||
@@ -571,14 +580,16 @@ def _modify_compose_file(
|
|||||||
service_config["command"] = start_command
|
service_config["command"] = start_command
|
||||||
|
|
||||||
if working_directory:
|
if working_directory:
|
||||||
service_config["working_dir"] = working_directory
|
service_config["working_dir"] = expand_container_path(
|
||||||
|
working_directory, home_dir
|
||||||
|
)
|
||||||
|
|
||||||
if extra_volumes:
|
if extra_volumes:
|
||||||
if "volumes" not in service_config:
|
if "volumes" not in service_config:
|
||||||
service_config["volumes"] = []
|
service_config["volumes"] = []
|
||||||
for vol in extra_volumes:
|
for vol in extra_volumes:
|
||||||
source = vol.get("source", "")
|
source = vol.get("source", "")
|
||||||
target = vol.get("target", "")
|
target = expand_container_path(vol.get("target", ""), home_dir)
|
||||||
vol_type = vol.get("type", "bind")
|
vol_type = vol.get("type", "bind")
|
||||||
if vol_type == "bind":
|
if vol_type == "bind":
|
||||||
service_config["volumes"].append(f"{source}:{target}")
|
service_config["volumes"].append(f"{source}:{target}")
|
||||||
@@ -826,6 +837,9 @@ services:
|
|||||||
deep_merge(dict(base_def.manifest), manifest)
|
deep_merge(dict(base_def.manifest), manifest)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Determine home directory for path expansion
|
||||||
|
home_dir = get_manifest_home_dir(manifest)
|
||||||
|
|
||||||
image_tag = compute_image_tag(tool_type.name, manifest)
|
image_tag = compute_image_tag(tool_type.name, manifest)
|
||||||
|
|
||||||
# Build image during creation so start is fast
|
# Build image during creation so start is fast
|
||||||
@@ -1105,11 +1119,11 @@ async def _prepare_manifest_instance(
|
|||||||
env_vars: dict,
|
env_vars: dict,
|
||||||
extra_volumes: list,
|
extra_volumes: list,
|
||||||
working_directory: str | None,
|
working_directory: str | None,
|
||||||
) -> tuple[str, str, dict]:
|
) -> tuple[str, str, dict, str]:
|
||||||
"""Build image and generate compose from a manifest-based tool type.
|
"""Build image and generate compose from a manifest-based tool type.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (image_tag, compose_content, resolved_manifest)
|
Tuple of (image_tag, compose_content, resolved_manifest, home_dir)
|
||||||
"""
|
"""
|
||||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||||
|
|
||||||
@@ -1220,7 +1234,8 @@ async def _prepare_manifest_instance(
|
|||||||
instance.image_tag = image_tag
|
instance.image_tag = image_tag
|
||||||
instance.manifest_compiled_at = datetime.now()
|
instance.manifest_compiled_at = datetime.now()
|
||||||
|
|
||||||
return image_tag, compose_content, manifest
|
home_dir = get_manifest_home_dir(manifest)
|
||||||
|
return image_tag, compose_content, manifest, home_dir
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -1283,6 +1298,15 @@ async def start_instance(
|
|||||||
working_directory = None
|
working_directory = None
|
||||||
extra_volumes = []
|
extra_volumes = []
|
||||||
|
|
||||||
|
# Fetch tool type early to determine home directory
|
||||||
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
home_dir = "/root"
|
||||||
|
if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
|
||||||
|
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||||
|
manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id)
|
||||||
|
if manifest_def:
|
||||||
|
home_dir = get_manifest_home_dir(dict(manifest_def.manifest))
|
||||||
|
|
||||||
# Apply selected config profile if any
|
# Apply selected config profile if any
|
||||||
instance_dir = os.path.dirname(instance.compose_path)
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
if instance.selected_config_profile_id is not None:
|
if instance.selected_config_profile_id is not None:
|
||||||
@@ -1291,7 +1315,7 @@ async def start_instance(
|
|||||||
session, instance.selected_config_profile_id
|
session, instance.selected_config_profile_id
|
||||||
)
|
)
|
||||||
profile_env, profile_files, profile_mounts, profile_hints = (
|
profile_env, profile_files, profile_mounts, profile_hints = (
|
||||||
apply_resolved_profile(instance_dir, resolved)
|
apply_resolved_profile(instance_dir, resolved, home_dir)
|
||||||
)
|
)
|
||||||
# Profile env vars override tool config env vars
|
# Profile env vars override tool config env vars
|
||||||
env_vars.update(profile_env)
|
env_vars.update(profile_env)
|
||||||
@@ -1301,7 +1325,7 @@ async def start_instance(
|
|||||||
extra_volumes.extend(profile_mounts)
|
extra_volumes.extend(profile_mounts)
|
||||||
# Git repository mounts are resolved and added
|
# Git repository mounts are resolved and added
|
||||||
git_mount_volumes = await _resolve_git_mounts(
|
git_mount_volumes = await _resolve_git_mounts(
|
||||||
session, resolved, instance_dir, working_directory
|
session, resolved, instance_dir, working_directory, home_dir
|
||||||
)
|
)
|
||||||
extra_volumes.extend(git_mount_volumes)
|
extra_volumes.extend(git_mount_volumes)
|
||||||
# Profile runtime hints override tool config values
|
# Profile runtime hints override tool config values
|
||||||
@@ -1345,7 +1369,6 @@ async def start_instance(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ── MANIFEST-BASED FLOW ──────────────────────────────────────
|
# ── MANIFEST-BASED FLOW ──────────────────────────────────────
|
||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
|
||||||
resolved_manifest = None
|
resolved_manifest = None
|
||||||
|
|
||||||
if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
|
if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
|
||||||
@@ -1362,6 +1385,7 @@ async def start_instance(
|
|||||||
image_tag,
|
image_tag,
|
||||||
compose_content,
|
compose_content,
|
||||||
resolved_manifest,
|
resolved_manifest,
|
||||||
|
_home_dir,
|
||||||
) = await _prepare_manifest_instance(
|
) = await _prepare_manifest_instance(
|
||||||
session=session,
|
session=session,
|
||||||
instance=instance,
|
instance=instance,
|
||||||
@@ -1420,6 +1444,7 @@ async def start_instance(
|
|||||||
start_command,
|
start_command,
|
||||||
working_directory,
|
working_directory,
|
||||||
extra_volumes,
|
extra_volumes,
|
||||||
|
home_dir,
|
||||||
)
|
)
|
||||||
logger.debug("Modified compose file for instance %s", instance.id)
|
logger.debug("Modified compose file for instance %s", instance.id)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ and cycle protection.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -57,7 +58,9 @@ class ResolvedProfile:
|
|||||||
included_profiles: list[dict[str, Any]] = field(default_factory=list)
|
included_profiles: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
def _detect_cycle(profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]) -> bool:
|
def _detect_cycle(
|
||||||
|
profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]
|
||||||
|
) -> bool:
|
||||||
"""Detect if adding profile_id to path would create a cycle.
|
"""Detect if adding profile_id to path would create a cycle.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -252,7 +255,9 @@ async def _resolve_profile_recursive(
|
|||||||
"""
|
"""
|
||||||
if _detect_cycle(profile_id, visited, path):
|
if _detect_cycle(profile_id, visited, path):
|
||||||
cycle_path = " -> ".join(str(p) for p in path + [profile_id])
|
cycle_path = " -> ".join(str(p) for p in path + [profile_id])
|
||||||
raise ConfigProfileCycleError(f"Cycle detected in profile includes: {cycle_path}")
|
raise ConfigProfileCycleError(
|
||||||
|
f"Cycle detected in profile includes: {cycle_path}"
|
||||||
|
)
|
||||||
|
|
||||||
profile = await session.get(ConfigProfile, profile_id)
|
profile = await session.get(ConfigProfile, profile_id)
|
||||||
if profile is None:
|
if profile is None:
|
||||||
@@ -279,13 +284,18 @@ async def _resolve_profile_recursive(
|
|||||||
included = await _resolve_profile_recursive(
|
included = await _resolve_profile_recursive(
|
||||||
session, include.included_profile_id, new_visited, new_path
|
session, include.included_profile_id, new_visited, new_path
|
||||||
)
|
)
|
||||||
result.included_profiles.append({
|
result.included_profiles.append(
|
||||||
"id": str(included.profile_id),
|
{
|
||||||
"name": included.profile_name,
|
"id": str(included.profile_id),
|
||||||
})
|
"name": included.profile_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
result.env_vars = _merge_env_vars(
|
result.env_vars = _merge_env_vars(
|
||||||
result.env_vars, included.env_vars, result.env_overrides, included.profile_name
|
result.env_vars,
|
||||||
|
included.env_vars,
|
||||||
|
result.env_overrides,
|
||||||
|
included.profile_name,
|
||||||
)
|
)
|
||||||
result.runtime_hints = _merge_runtime_hints(
|
result.runtime_hints = _merge_runtime_hints(
|
||||||
result.runtime_hints,
|
result.runtime_hints,
|
||||||
@@ -429,6 +439,7 @@ async def check_include_cycle(
|
|||||||
def apply_resolved_profile(
|
def apply_resolved_profile(
|
||||||
instance_dir: str,
|
instance_dir: str,
|
||||||
resolved: ResolvedProfile,
|
resolved: ResolvedProfile,
|
||||||
|
home_dir: str = "/root",
|
||||||
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
|
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
|
||||||
"""Apply a resolved profile to an instance directory.
|
"""Apply a resolved profile to an instance directory.
|
||||||
|
|
||||||
@@ -458,14 +469,19 @@ def apply_resolved_profile(
|
|||||||
try:
|
try:
|
||||||
full_path.resolve().relative_to(instance_path.resolve())
|
full_path.resolve().relative_to(instance_path.resolve())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning("Profile file path escapes instance directory: %s", file_path)
|
logger.warning(
|
||||||
|
"Profile file path escapes instance directory: %s", file_path
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
full_path.write_text(content)
|
full_path.write_text(content)
|
||||||
|
|
||||||
# Stage mount files and prepare volume mounts
|
# Stage mount files and prepare volume mounts
|
||||||
for mount in resolved.mounts.values():
|
for mount in resolved.mounts.values():
|
||||||
mount_dir = instance_path / "mounts" / mount.target.lstrip("/").replace("/", "_")
|
expanded_target = expand_container_path(mount.target, home_dir)
|
||||||
|
mount_dir = (
|
||||||
|
instance_path / "mounts" / expanded_target.lstrip("/").replace("/", "_")
|
||||||
|
)
|
||||||
mount_dir.mkdir(parents=True, exist_ok=True)
|
mount_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
for file_path, content in mount.files.items():
|
for file_path, content in mount.files.items():
|
||||||
@@ -478,15 +494,41 @@ def apply_resolved_profile(
|
|||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
full_path.write_text(content)
|
full_path.write_text(content)
|
||||||
|
|
||||||
volume_mounts.append({
|
volume_mounts.append(
|
||||||
"source": str(mount_dir),
|
{
|
||||||
"target": mount.target,
|
"source": str(mount_dir),
|
||||||
"type": "bind",
|
"target": expanded_target,
|
||||||
})
|
"type": "bind",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return env_vars, files, volume_mounts, resolved.runtime_hints
|
return env_vars, files, volume_mounts, resolved.runtime_hints
|
||||||
|
|
||||||
|
|
||||||
|
def expand_container_path(path: str, home_dir: str) -> str:
|
||||||
|
"""Expand ~ and $HOME in a container path to the actual home directory.
|
||||||
|
|
||||||
|
Only expands at the start of the path (e.g., ~/foo, $HOME/foo, $HOME).
|
||||||
|
Leaves mid-string occurrences unchanged.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Container path that may contain ~ or $HOME.
|
||||||
|
home_dir: The container's home directory (e.g., /home/user or /root).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path with ~ and $HOME expanded.
|
||||||
|
"""
|
||||||
|
if path.startswith("~/"):
|
||||||
|
return os.path.join(home_dir, path[2:])
|
||||||
|
if path == "~":
|
||||||
|
return home_dir
|
||||||
|
if path.startswith("$HOME/"):
|
||||||
|
return home_dir + "/" + path[6:]
|
||||||
|
if path == "$HOME":
|
||||||
|
return home_dir
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
|
def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
|
||||||
"""Convert a ResolvedProfile to a plain dict for serialization.
|
"""Convert a ResolvedProfile to a plain dict for serialization.
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,11 @@ def compile_dockerfile(manifest: dict) -> str:
|
|||||||
lines.append(f"RUN groupadd -g {gid} {name} && \\")
|
lines.append(f"RUN groupadd -g {gid} {name} && \\")
|
||||||
lines.append(f" useradd -u {uid} -g {gid} {create_home}-s {shell} {name}")
|
lines.append(f" useradd -u {uid} -g {gid} {create_home}-s {shell} {name}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
# Set HOME and USER for runtime compatibility
|
||||||
|
home = f"/home/{name}"
|
||||||
|
lines.append(f"ENV HOME={home}")
|
||||||
|
lines.append(f"ENV USER={name}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
# Build scripts
|
# Build scripts
|
||||||
build_scripts = manifest.get("scripts", {}).get("build", [])
|
build_scripts = manifest.get("scripts", {}).get("build", [])
|
||||||
@@ -333,6 +338,21 @@ def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest_home_dir(manifest: dict) -> str:
|
||||||
|
"""Get the home directory for a container based on manifest user config.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest: Fully resolved manifest JSON.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Home directory path (e.g., /home/user or /root).
|
||||||
|
"""
|
||||||
|
user = manifest.get("user")
|
||||||
|
if user and user.get("name"):
|
||||||
|
return f"/home/{user['name']}"
|
||||||
|
return "/root"
|
||||||
|
|
||||||
|
|
||||||
def compute_image_tag(tool_name: str, manifest: dict) -> str:
|
def compute_image_tag(tool_name: str, manifest: dict) -> str:
|
||||||
"""Compute a deterministic image tag from manifest content.
|
"""Compute a deterministic image tag from manifest content.
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ class TestMergeFunctions:
|
|||||||
def test_merge_mounts_file_override(self) -> None:
|
def test_merge_mounts_file_override(self) -> None:
|
||||||
"""Test mount file map merging with overrides."""
|
"""Test mount file map merging with overrides."""
|
||||||
from src.services.config_profile_resolver import ResolvedMount
|
from src.services.config_profile_resolver import ResolvedMount
|
||||||
|
|
||||||
result = _merge_mounts(
|
result = _merge_mounts(
|
||||||
{"/app": ResolvedMount(target="/app", mode="rw", files={"a.txt": "old"})},
|
{"/app": ResolvedMount(target="/app", mode="rw", files={"a.txt": "old"})},
|
||||||
[{"target": "/app", "mode": "rw", "files": {"a.txt": "new"}}],
|
[{"target": "/app", "mode": "rw", "files": {"a.txt": "new"}}],
|
||||||
@@ -86,6 +87,7 @@ class TestMergeFunctions:
|
|||||||
def test_merge_mounts_mode_conflict(self) -> None:
|
def test_merge_mounts_mode_conflict(self) -> None:
|
||||||
"""Test that mount mode conflicts are resolved (later wins)."""
|
"""Test that mount mode conflicts are resolved (later wins)."""
|
||||||
from src.services.config_profile_resolver import ResolvedMount
|
from src.services.config_profile_resolver import ResolvedMount
|
||||||
|
|
||||||
overrides = {}
|
overrides = {}
|
||||||
result = _merge_mounts(
|
result = _merge_mounts(
|
||||||
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
|
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
|
||||||
@@ -100,7 +102,13 @@ class TestMergeFunctions:
|
|||||||
"""Test basic git mount merging normalizes to mappings format."""
|
"""Test basic git mount merging normalizes to mappings format."""
|
||||||
result = _merge_git_mounts(
|
result = _merge_git_mounts(
|
||||||
[],
|
[],
|
||||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
[
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
}
|
||||||
|
],
|
||||||
"source",
|
"source",
|
||||||
)
|
)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
@@ -111,8 +119,22 @@ class TestMergeFunctions:
|
|||||||
def test_merge_git_mounts_concatenate_same_repo_branch(self) -> None:
|
def test_merge_git_mounts_concatenate_same_repo_branch(self) -> None:
|
||||||
"""Test that git mounts with same repo+branch concatenate mappings."""
|
"""Test that git mounts with same repo+branch concatenate mappings."""
|
||||||
result = _merge_git_mounts(
|
result = _merge_git_mounts(
|
||||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
|
[
|
||||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": "src", "target_path": "/src", "branch": "main"}],
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
"branch": "main",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": "src",
|
||||||
|
"target_path": "/src",
|
||||||
|
"branch": "main",
|
||||||
|
}
|
||||||
|
],
|
||||||
"source",
|
"source",
|
||||||
)
|
)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
@@ -125,8 +147,22 @@ class TestMergeFunctions:
|
|||||||
def test_merge_git_mounts_dedup_same_mapping(self) -> None:
|
def test_merge_git_mounts_dedup_same_mapping(self) -> None:
|
||||||
"""Test that duplicate mappings are deduplicated."""
|
"""Test that duplicate mappings are deduplicated."""
|
||||||
result = _merge_git_mounts(
|
result = _merge_git_mounts(
|
||||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
|
[
|
||||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
"branch": "main",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
"branch": "main",
|
||||||
|
}
|
||||||
|
],
|
||||||
"source",
|
"source",
|
||||||
)
|
)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
@@ -135,19 +171,48 @@ class TestMergeFunctions:
|
|||||||
def test_merge_git_mounts_different_repos(self) -> None:
|
def test_merge_git_mounts_different_repos(self) -> None:
|
||||||
"""Test that git mounts with different repos are preserved."""
|
"""Test that git mounts with different repos are preserved."""
|
||||||
result = _merge_git_mounts(
|
result = _merge_git_mounts(
|
||||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
[
|
||||||
[{"remote_url": "https://github.com/user/repo2.git", "source_path": ".", "target_path": "/config"}],
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo2.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/config",
|
||||||
|
}
|
||||||
|
],
|
||||||
"source",
|
"source",
|
||||||
)
|
)
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
urls = {m["remote_url"] for m in result}
|
urls = {m["remote_url"] for m in result}
|
||||||
assert urls == {"https://github.com/user/repo1.git", "https://github.com/user/repo2.git"}
|
assert urls == {
|
||||||
|
"https://github.com/user/repo1.git",
|
||||||
|
"https://github.com/user/repo2.git",
|
||||||
|
}
|
||||||
|
|
||||||
def test_merge_git_mounts_different_branches(self) -> None:
|
def test_merge_git_mounts_different_branches(self) -> None:
|
||||||
"""Test that same repo with different branches are kept separate."""
|
"""Test that same repo with different branches are kept separate."""
|
||||||
result = _merge_git_mounts(
|
result = _merge_git_mounts(
|
||||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
|
[
|
||||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "dev"}],
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
"branch": "main",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
"branch": "dev",
|
||||||
|
}
|
||||||
|
],
|
||||||
"source",
|
"source",
|
||||||
)
|
)
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
@@ -181,7 +246,9 @@ class TestResolveProfile:
|
|||||||
assert result.files == {"test.txt": "content"}
|
assert result.files == {"test.txt": "content"}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_profile_with_includes(self, db_session: AsyncSession) -> None:
|
async def test_resolve_profile_with_includes(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
"""Test resolving a profile that includes another."""
|
"""Test resolving a profile that includes another."""
|
||||||
user_id = uuid.uuid4()
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
@@ -225,7 +292,9 @@ class TestResolveProfile:
|
|||||||
assert result.included_profiles[0]["name"] == "base"
|
assert result.included_profiles[0]["name"] == "base"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_profile_child_overrides_parent(self, db_session: AsyncSession) -> None:
|
async def test_resolve_profile_child_overrides_parent(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
"""Test that child profile values override parent values."""
|
"""Test that child profile values override parent values."""
|
||||||
user_id = uuid.uuid4()
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
@@ -262,7 +331,9 @@ class TestResolveProfile:
|
|||||||
assert result.env_overrides == {"VAR": "child"}
|
assert result.env_overrides == {"VAR": "child"}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_profile_cycle_detection(self, db_session: AsyncSession) -> None:
|
async def test_resolve_profile_cycle_detection(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
"""Test that cycles are detected during resolution."""
|
"""Test that cycles are detected during resolution."""
|
||||||
user_id = uuid.uuid4()
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
@@ -308,10 +379,12 @@ class TestResolveProfile:
|
|||||||
await resolve_profile(db_session, profile_a.id)
|
await resolve_profile(db_session, profile_a.id)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_profile_with_git_mounts(self, db_session: AsyncSession) -> None:
|
async def test_resolve_profile_with_git_mounts(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
"""Test resolving a profile with git mounts normalizes to mappings."""
|
"""Test resolving a profile with git mounts normalizes to mappings."""
|
||||||
user_id = uuid.uuid4()
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
profile = ConfigProfile(
|
profile = ConfigProfile(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -319,23 +392,31 @@ class TestResolveProfile:
|
|||||||
env_vars={},
|
env_vars={},
|
||||||
files={},
|
files={},
|
||||||
git_mounts=[
|
git_mounts=[
|
||||||
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
db_session.add(profile)
|
db_session.add(profile)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
result = await resolve_profile(db_session, profile.id)
|
result = await resolve_profile(db_session, profile.id)
|
||||||
assert len(result.git_mounts) == 1
|
assert len(result.git_mounts) == 1
|
||||||
assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git"
|
assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git"
|
||||||
assert "mappings" in result.git_mounts[0]
|
assert "mappings" in result.git_mounts[0]
|
||||||
assert result.git_mounts[0]["mappings"] == [{"source_path": ".", "target_path": "/app"}]
|
assert result.git_mounts[0]["mappings"] == [
|
||||||
|
{"source_path": ".", "target_path": "/app"}
|
||||||
|
]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_profile_with_git_mount_includes(self, db_session: AsyncSession) -> None:
|
async def test_resolve_profile_with_git_mount_includes(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
"""Test resolving a profile that includes another with git mounts."""
|
"""Test resolving a profile that includes another with git mounts."""
|
||||||
user_id = uuid.uuid4()
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
# Create base profile with git mount
|
# Create base profile with git mount
|
||||||
base = ConfigProfile(
|
base = ConfigProfile(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
@@ -344,11 +425,15 @@ class TestResolveProfile:
|
|||||||
env_vars={},
|
env_vars={},
|
||||||
files={},
|
files={},
|
||||||
git_mounts=[
|
git_mounts=[
|
||||||
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
db_session.add(base)
|
db_session.add(base)
|
||||||
|
|
||||||
# Create child profile with its own git mount
|
# Create child profile with its own git mount
|
||||||
child = ConfigProfile(
|
child = ConfigProfile(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
@@ -357,12 +442,16 @@ class TestResolveProfile:
|
|||||||
env_vars={},
|
env_vars={},
|
||||||
files={},
|
files={},
|
||||||
git_mounts=[
|
git_mounts=[
|
||||||
{"remote_url": "https://github.com/user/repo2.git", "source_path": "config", "target_path": "/config"},
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo2.git",
|
||||||
|
"source_path": "config",
|
||||||
|
"target_path": "/config",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
db_session.add(child)
|
db_session.add(child)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
# Create include relationship
|
# Create include relationship
|
||||||
include = ConfigProfileInclude(
|
include = ConfigProfileInclude(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
@@ -372,11 +461,14 @@ class TestResolveProfile:
|
|||||||
)
|
)
|
||||||
db_session.add(include)
|
db_session.add(include)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
result = await resolve_profile(db_session, child.id)
|
result = await resolve_profile(db_session, child.id)
|
||||||
assert len(result.git_mounts) == 2
|
assert len(result.git_mounts) == 2
|
||||||
urls = {m["remote_url"] for m in result.git_mounts}
|
urls = {m["remote_url"] for m in result.git_mounts}
|
||||||
assert urls == {"https://github.com/user/repo1.git", "https://github.com/user/repo2.git"}
|
assert urls == {
|
||||||
|
"https://github.com/user/repo1.git",
|
||||||
|
"https://github.com/user/repo2.git",
|
||||||
|
}
|
||||||
for m in result.git_mounts:
|
for m in result.git_mounts:
|
||||||
assert "mappings" in m
|
assert "mappings" in m
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,10 @@ class TestResolveSingleGitMount:
|
|||||||
async def test_missing_remote_url(self) -> None:
|
async def test_missing_remote_url(self) -> None:
|
||||||
"""Git mount without remote_url returns empty list."""
|
"""Git mount without remote_url returns empty list."""
|
||||||
result = await _resolve_single_git_mount(
|
result = await _resolve_single_git_mount(
|
||||||
MagicMock(), {"mappings": [{"source_path": ".", "target_path": "/app"}]}, "/tmp", None
|
MagicMock(),
|
||||||
|
{"mappings": [{"source_path": ".", "target_path": "/app"}]},
|
||||||
|
"/tmp",
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
@@ -157,7 +160,10 @@ class TestResolveSingleGitMount:
|
|||||||
"""Git mount without instance_dir returns empty list."""
|
"""Git mount without instance_dir returns empty list."""
|
||||||
result = await _resolve_single_git_mount(
|
result = await _resolve_single_git_mount(
|
||||||
MagicMock(),
|
MagicMock(),
|
||||||
{"remote_url": "https://github.com/user/repo.git", "mappings": [{"source_path": ".", "target_path": "/app"}]},
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"mappings": [{"source_path": ".", "target_path": "/app"}],
|
||||||
|
},
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Unit tests for ~ / $HOME expansion in container paths."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.api.tool_instances import _resolve_git_mount_mappings
|
||||||
|
from src.services.config_profile_resolver import expand_container_path
|
||||||
|
from src.services.manifest_compiler import get_manifest_home_dir
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpandContainerPath:
|
||||||
|
"""Tests for expand_container_path helper."""
|
||||||
|
|
||||||
|
def test_tilde_slash_expands(self) -> None:
|
||||||
|
"""~/foo should expand to home_dir/foo."""
|
||||||
|
assert expand_container_path("~/workspace", "/home/user") == "/home/user/workspace"
|
||||||
|
|
||||||
|
def test_tilde_alone_expands(self) -> None:
|
||||||
|
"""~ should expand to home_dir."""
|
||||||
|
assert expand_container_path("~", "/home/user") == "/home/user"
|
||||||
|
|
||||||
|
def test_dollar_home_slash_expands(self) -> None:
|
||||||
|
"""$HOME/foo should expand to home_dir/foo."""
|
||||||
|
assert expand_container_path("$HOME/workspace", "/home/user") == "/home/user/workspace"
|
||||||
|
|
||||||
|
def test_dollar_home_alone_expands(self) -> None:
|
||||||
|
"""$HOME should expand to home_dir."""
|
||||||
|
assert expand_container_path("$HOME", "/home/user") == "/home/user"
|
||||||
|
|
||||||
|
def test_absolute_path_unchanged(self) -> None:
|
||||||
|
"""Absolute paths should not be modified."""
|
||||||
|
assert expand_container_path("/app/workspace", "/home/user") == "/app/workspace"
|
||||||
|
|
||||||
|
def test_relative_path_unchanged(self) -> None:
|
||||||
|
"""Relative paths should not be modified."""
|
||||||
|
assert expand_container_path("workspace", "/home/user") == "workspace"
|
||||||
|
|
||||||
|
def test_tilde_in_middle_unchanged(self) -> None:
|
||||||
|
"""~ in the middle of a path should not expand."""
|
||||||
|
assert expand_container_path("/app/~user", "/home/user") == "/app/~user"
|
||||||
|
|
||||||
|
def test_dollar_home_in_middle_unchanged(self) -> None:
|
||||||
|
"""$HOME in the middle of a path should not expand."""
|
||||||
|
assert expand_container_path("/app/$HOMEuser", "/home/user") == "/app/$HOMEuser"
|
||||||
|
|
||||||
|
def test_root_home(self) -> None:
|
||||||
|
"""Expansion works with /root as home."""
|
||||||
|
assert expand_container_path("~/config", "/root") == "/root/config"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetManifestHomeDir:
|
||||||
|
"""Tests for get_manifest_home_dir helper."""
|
||||||
|
|
||||||
|
def test_with_user_block(self) -> None:
|
||||||
|
"""Manifest with user block returns /home/{name}."""
|
||||||
|
manifest = {"user": {"name": "developer", "uid": 1000, "gid": 1000}}
|
||||||
|
assert get_manifest_home_dir(manifest) == "/home/developer"
|
||||||
|
|
||||||
|
def test_without_user_block(self) -> None:
|
||||||
|
"""Manifest without user block returns /root."""
|
||||||
|
manifest = {"base_image": "ubuntu:24.04"}
|
||||||
|
assert get_manifest_home_dir(manifest) == "/root"
|
||||||
|
|
||||||
|
def test_with_empty_user_name(self) -> None:
|
||||||
|
"""Manifest with empty user name returns /root."""
|
||||||
|
manifest = {"user": {"name": "", "uid": 1000, "gid": 1000}}
|
||||||
|
assert get_manifest_home_dir(manifest) == "/root"
|
||||||
|
|
||||||
|
def test_with_none_user_name(self) -> None:
|
||||||
|
"""Manifest with None user name returns /root."""
|
||||||
|
manifest = {"user": {"name": None, "uid": 1000, "gid": 1000}}
|
||||||
|
assert get_manifest_home_dir(manifest) == "/root"
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveGitMountMappingsExpansion:
|
||||||
|
"""Tests that git mount mapping targets expand ~ and $HOME."""
|
||||||
|
|
||||||
|
def test_tilde_target_expansion(self, tmp_path) -> None:
|
||||||
|
"""Mapping with ~/repo target expands to home dir."""
|
||||||
|
(tmp_path / "src").mkdir()
|
||||||
|
mappings = [{"source_path": "src", "target_path": "~/repo"}]
|
||||||
|
result = _resolve_git_mount_mappings(str(tmp_path), mappings, None, "/home/user")
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["target"] == "/home/user/repo"
|
||||||
|
|
||||||
|
def test_dollar_home_target_expansion(self, tmp_path) -> None:
|
||||||
|
"""Mapping with $HOME/repo target expands to home dir."""
|
||||||
|
(tmp_path / "src").mkdir()
|
||||||
|
mappings = [{"source_path": "src", "target_path": "$HOME/repo"}]
|
||||||
|
result = _resolve_git_mount_mappings(str(tmp_path), mappings, None, "/home/user")
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["target"] == "/home/user/repo"
|
||||||
|
|
||||||
|
def test_absolute_target_unchanged(self, tmp_path) -> None:
|
||||||
|
"""Absolute target paths are not modified."""
|
||||||
|
(tmp_path / "src").mkdir()
|
||||||
|
mappings = [{"source_path": "src", "target_path": "/app/src"}]
|
||||||
|
result = _resolve_git_mount_mappings(str(tmp_path), mappings, None, "/home/user")
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["target"] == "/app/src"
|
||||||
@@ -8,6 +8,7 @@ from src.services.manifest_compiler import (
|
|||||||
compile_entrypoint,
|
compile_entrypoint,
|
||||||
compute_image_tag,
|
compute_image_tag,
|
||||||
deep_merge,
|
deep_merge,
|
||||||
|
get_manifest_home_dir,
|
||||||
merge_with_config,
|
merge_with_config,
|
||||||
resolve_base,
|
resolve_base,
|
||||||
)
|
)
|
||||||
@@ -161,6 +162,38 @@ class TestCompileDockerfile:
|
|||||||
df = compile_dockerfile(manifest)
|
df = compile_dockerfile(manifest)
|
||||||
assert 'CMD ["/bin/bash"]' in df
|
assert 'CMD ["/bin/bash"]' in df
|
||||||
|
|
||||||
|
def test_sets_home_env_for_user(self) -> None:
|
||||||
|
manifest = {
|
||||||
|
"base_image": "ubuntu:24.04",
|
||||||
|
"name": "test",
|
||||||
|
"user": {"name": "dev", "uid": 1001, "gid": 1001},
|
||||||
|
}
|
||||||
|
df = compile_dockerfile(manifest)
|
||||||
|
assert "ENV HOME=/home/dev" in df
|
||||||
|
assert "ENV USER=dev" in df
|
||||||
|
|
||||||
|
def test_no_home_env_without_user(self) -> None:
|
||||||
|
manifest = {"base_image": "ubuntu:24.04", "name": "test"}
|
||||||
|
df = compile_dockerfile(manifest)
|
||||||
|
assert "ENV HOME=" not in df
|
||||||
|
assert "ENV USER=" not in df
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetManifestHomeDir:
|
||||||
|
"""Tests for get_manifest_home_dir."""
|
||||||
|
|
||||||
|
def test_with_user_name(self) -> None:
|
||||||
|
manifest = {"user": {"name": "dev", "uid": 1001, "gid": 1001}}
|
||||||
|
assert get_manifest_home_dir(manifest) == "/home/dev"
|
||||||
|
|
||||||
|
def test_without_user(self) -> None:
|
||||||
|
manifest = {"base_image": "ubuntu:24.04"}
|
||||||
|
assert get_manifest_home_dir(manifest) == "/root"
|
||||||
|
|
||||||
|
def test_with_empty_user_name(self) -> None:
|
||||||
|
manifest = {"user": {"name": "", "uid": 1001, "gid": 1001}}
|
||||||
|
assert get_manifest_home_dir(manifest) == "/root"
|
||||||
|
|
||||||
|
|
||||||
class TestCompileEntrypoint:
|
class TestCompileEntrypoint:
|
||||||
"""Tests for compile_entrypoint."""
|
"""Tests for compile_entrypoint."""
|
||||||
|
|||||||
@@ -735,6 +735,7 @@ class TestStartInstanceManifestBranch:
|
|||||||
"headquarter/test:latest",
|
"headquarter/test:latest",
|
||||||
"services:\n app:\n image: test",
|
"services:\n app:\n image: test",
|
||||||
{"name": "test-manifest"},
|
{"name": "test-manifest"},
|
||||||
|
"/root",
|
||||||
)
|
)
|
||||||
|
|
||||||
instance = ToolInstance(
|
instance = ToolInstance(
|
||||||
|
|||||||
+108
-102
@@ -1,163 +1,169 @@
|
|||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
export interface ConfigProfile {
|
export interface ConfigProfile {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
project_id: string | null;
|
project_id: string | null;
|
||||||
tool_type_id: string | null;
|
tool_type_id: string | null;
|
||||||
env_vars: Record<string, string>;
|
env_vars: Record<string, string>;
|
||||||
runtime_hints: Record<string, unknown>;
|
runtime_hints: Record<string, unknown>;
|
||||||
mounts: ConfigProfileMount[];
|
mounts: ConfigProfileMount[];
|
||||||
git_mounts: GitMount[];
|
git_mounts: GitMount[];
|
||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
includes: ConfigProfileInclude[];
|
includes: ConfigProfileInclude[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigProfileMount {
|
export interface ConfigProfileMount {
|
||||||
target: string;
|
target: string;
|
||||||
mode: "ro" | "rw";
|
mode: "ro" | "rw";
|
||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitMountMapping {
|
export interface GitMountMapping {
|
||||||
source_path: string;
|
source_path: string;
|
||||||
target_path: string;
|
target_path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitMount {
|
export interface GitMount {
|
||||||
remote_url: string;
|
remote_url: string;
|
||||||
branch?: string;
|
branch?: string;
|
||||||
mappings: GitMountMapping[];
|
mappings: GitMountMapping[];
|
||||||
// Legacy fields (for backward compatibility when reading old data)
|
// Legacy fields (for backward compatibility when reading old data)
|
||||||
source_path?: string;
|
source_path?: string;
|
||||||
target_path?: string;
|
target_path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigProfileInclude {
|
export interface ConfigProfileInclude {
|
||||||
id: string;
|
id: string;
|
||||||
included_profile_id: string;
|
included_profile_id: string;
|
||||||
order_index: number;
|
order_index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedProfile {
|
export interface ResolvedProfile {
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
profile_name: string;
|
profile_name: string;
|
||||||
env_vars: Record<string, string>;
|
env_vars: Record<string, string>;
|
||||||
runtime_hints: Record<string, unknown>;
|
runtime_hints: Record<string, unknown>;
|
||||||
mounts: ResolvedMount[];
|
mounts: ResolvedMount[];
|
||||||
git_mounts: GitMount[];
|
git_mounts: GitMount[];
|
||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
overrides: {
|
overrides: {
|
||||||
env_vars: Record<string, string>;
|
env_vars: Record<string, string>;
|
||||||
runtime_hints: Record<string, string>;
|
runtime_hints: Record<string, string>;
|
||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
mounts: Record<string, string>;
|
mounts: Record<string, string>;
|
||||||
};
|
};
|
||||||
included_profiles: Array<{ id: string; name: string }>;
|
included_profiles: Array<{ id: string; name: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedMount {
|
export interface ResolvedMount {
|
||||||
target: string;
|
target: string;
|
||||||
mode: "ro" | "rw";
|
mode: "ro" | "rw";
|
||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
overridden_files: Record<string, string>;
|
overridden_files: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateConfigProfileRequest {
|
export interface CreateConfigProfileRequest {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
project_id?: string;
|
project_id?: string;
|
||||||
tool_type_id?: string;
|
tool_type_id?: string;
|
||||||
env_vars?: Record<string, string>;
|
env_vars?: Record<string, string>;
|
||||||
runtime_hints?: Record<string, unknown>;
|
runtime_hints?: Record<string, unknown>;
|
||||||
mounts?: ConfigProfileMount[];
|
mounts?: ConfigProfileMount[];
|
||||||
git_mounts?: GitMount[];
|
git_mounts?: GitMount[];
|
||||||
files?: Record<string, string>;
|
files?: Record<string, string>;
|
||||||
is_default?: boolean;
|
is_default?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateConfigProfileRequest {
|
export interface UpdateConfigProfileRequest {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
project_id?: string;
|
project_id?: string;
|
||||||
tool_type_id?: string;
|
tool_type_id?: string;
|
||||||
env_vars?: Record<string, string>;
|
env_vars?: Record<string, string>;
|
||||||
runtime_hints?: Record<string, unknown>;
|
runtime_hints?: Record<string, unknown>;
|
||||||
mounts?: ConfigProfileMount[];
|
mounts?: ConfigProfileMount[];
|
||||||
git_mounts?: GitMount[];
|
git_mounts?: GitMount[];
|
||||||
files?: Record<string, string>;
|
files?: Record<string, string>;
|
||||||
is_default?: boolean;
|
is_default?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateIncludesRequest {
|
export interface UpdateIncludesRequest {
|
||||||
includes: string[];
|
includes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listConfigProfiles = async (
|
export const listConfigProfiles = async (
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
toolTypeId?: string
|
toolTypeId?: string,
|
||||||
): Promise<ConfigProfile[]> => {
|
): Promise<ConfigProfile[]> => {
|
||||||
const response = await apiClient.get<ConfigProfile[]>("/config-profiles", {
|
const response = await apiClient.get<ConfigProfile[]>("/config-profiles", {
|
||||||
params: { project_id: projectId, tool_type_id: toolTypeId },
|
params: { project_id: projectId, tool_type_id: toolTypeId },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getConfigProfile = async (id: string): Promise<ConfigProfile> => {
|
export const getConfigProfile = async (id: string): Promise<ConfigProfile> => {
|
||||||
const response = await apiClient.get<ConfigProfile>(`/config-profiles/${id}`);
|
const response = await apiClient.get<ConfigProfile>(`/config-profiles/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createConfigProfile = async (
|
export const createConfigProfile = async (
|
||||||
data: CreateConfigProfileRequest
|
data: CreateConfigProfileRequest,
|
||||||
): Promise<ConfigProfile> => {
|
): Promise<ConfigProfile> => {
|
||||||
const response = await apiClient.post<ConfigProfile>("/config-profiles", data);
|
const response = await apiClient.post<ConfigProfile>(
|
||||||
return response.data;
|
"/config-profiles",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateConfigProfile = async (
|
export const updateConfigProfile = async (
|
||||||
id: string,
|
id: string,
|
||||||
data: UpdateConfigProfileRequest
|
data: UpdateConfigProfileRequest,
|
||||||
): Promise<ConfigProfile> => {
|
): Promise<ConfigProfile> => {
|
||||||
const response = await apiClient.put<ConfigProfile>(`/config-profiles/${id}`, data);
|
const response = await apiClient.put<ConfigProfile>(
|
||||||
return response.data;
|
`/config-profiles/${id}`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteConfigProfile = async (id: string): Promise<void> => {
|
export const deleteConfigProfile = async (id: string): Promise<void> => {
|
||||||
await apiClient.delete(`/config-profiles/${id}`);
|
await apiClient.delete(`/config-profiles/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProfileIncludes = async (
|
export const updateProfileIncludes = async (
|
||||||
id: string,
|
id: string,
|
||||||
data: UpdateIncludesRequest
|
data: UpdateIncludesRequest,
|
||||||
): Promise<ConfigProfile> => {
|
): Promise<ConfigProfile> => {
|
||||||
const response = await apiClient.put<ConfigProfile>(
|
const response = await apiClient.put<ConfigProfile>(
|
||||||
`/config-profiles/${id}/includes`,
|
`/config-profiles/${id}/includes`,
|
||||||
data
|
data,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const previewConfigProfile = async (
|
export const previewConfigProfile = async (
|
||||||
id: string
|
id: string,
|
||||||
): Promise<ResolvedProfile> => {
|
): Promise<ResolvedProfile> => {
|
||||||
const response = await apiClient.get<ResolvedProfile>(
|
const response = await apiClient.get<ResolvedProfile>(
|
||||||
`/config-profiles/${id}/preview`
|
`/config-profiles/${id}/preview`,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveDefaultProfile = async (
|
export const resolveDefaultProfile = async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
toolTypeId: string
|
toolTypeId: string,
|
||||||
): Promise<{ profile_id: string | null; profile_name: string | null }> => {
|
): Promise<{ profile_id: string | null; profile_name: string | null }> => {
|
||||||
const response = await apiClient.get("/config-profiles/defaults/resolve", {
|
const response = await apiClient.get("/config-profiles/defaults/resolve", {
|
||||||
params: { project_id: projectId, tool_type_id: toolTypeId },
|
params: { project_id: projectId, tool_type_id: toolTypeId },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,309 +3,421 @@ import { Icon } from "./icon";
|
|||||||
import type { GitMount, GitMountMapping } from "../api/config_profiles";
|
import type { GitMount, GitMountMapping } from "../api/config_profiles";
|
||||||
|
|
||||||
interface GitMountEditorProps {
|
interface GitMountEditorProps {
|
||||||
mounts: GitMount[];
|
mounts: GitMount[];
|
||||||
onChange: (mounts: GitMount[]) => void;
|
onChange: (mounts: GitMount[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMount(mount: GitMount): GitMount {
|
function normalizeMount(mount: GitMount): GitMount {
|
||||||
// Auto-convert legacy source_path + target_path to mappings
|
// Auto-convert legacy source_path + target_path to mappings
|
||||||
if ((!mount.mappings || mount.mappings.length === 0) && mount.source_path !== undefined && mount.target_path !== undefined) {
|
if (
|
||||||
return {
|
(!mount.mappings || mount.mappings.length === 0) &&
|
||||||
remote_url: mount.remote_url,
|
mount.source_path !== undefined &&
|
||||||
branch: mount.branch,
|
mount.target_path !== undefined
|
||||||
mappings: [{ source_path: mount.source_path || ".", target_path: mount.target_path }],
|
) {
|
||||||
};
|
return {
|
||||||
}
|
remote_url: mount.remote_url,
|
||||||
return mount;
|
branch: mount.branch,
|
||||||
|
mappings: [
|
||||||
|
{
|
||||||
|
source_path: mount.source_path || ".",
|
||||||
|
target_path: mount.target_path,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return mount;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMounts(mounts: GitMount[]): GitMount[] {
|
function normalizeMounts(mounts: GitMount[]): GitMount[] {
|
||||||
return mounts.map(normalizeMount);
|
return mounts.map(normalizeMount);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
||||||
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() => normalizeMounts(mounts));
|
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() =>
|
||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
normalizeMounts(mounts),
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
);
|
||||||
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNormalizedMounts(normalizeMounts(mounts));
|
setNormalizedMounts(normalizeMounts(mounts));
|
||||||
}, [mounts]);
|
}, [mounts]);
|
||||||
|
|
||||||
const handleAdd = (mount: GitMount) => {
|
const handleAdd = (mount: GitMount) => {
|
||||||
const updated = [...normalizedMounts, normalizeMount(mount)];
|
const updated = [...normalizedMounts, normalizeMount(mount)];
|
||||||
setNormalizedMounts(updated);
|
setNormalizedMounts(updated);
|
||||||
onChange(updated);
|
onChange(updated);
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = (index: number, updated: GitMount) => {
|
const handleUpdate = (index: number, updated: GitMount) => {
|
||||||
const updatedMounts = [...normalizedMounts];
|
const updatedMounts = [...normalizedMounts];
|
||||||
updatedMounts[index] = normalizeMount(updated);
|
updatedMounts[index] = normalizeMount(updated);
|
||||||
setNormalizedMounts(updatedMounts);
|
setNormalizedMounts(updatedMounts);
|
||||||
onChange(updatedMounts);
|
onChange(updatedMounts);
|
||||||
setEditingIndex(null);
|
setEditingIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemove = (index: number) => {
|
const handleRemove = (index: number) => {
|
||||||
const updated = normalizedMounts.filter((_, i) => i !== index);
|
const updated = normalizedMounts.filter((_, i) => i !== index);
|
||||||
setNormalizedMounts(updated);
|
setNormalizedMounts(updated);
|
||||||
onChange(updated);
|
onChange(updated);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="git-mount-editor">
|
<div className="git-mount-editor">
|
||||||
<h4 style={{ margin: "0 0 0.75rem 0" }}>Git Mounts</h4>
|
<h4 style={{ margin: "0 0 0.75rem 0" }}>Git Mounts</h4>
|
||||||
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>
|
<p
|
||||||
Clone a repository once and mount multiple directories from it.
|
className="muted"
|
||||||
</p>
|
style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}
|
||||||
|
>
|
||||||
|
Clone a repository once and mount multiple directories from it.
|
||||||
|
</p>
|
||||||
|
|
||||||
{normalizedMounts.length > 0 && (
|
{normalizedMounts.length > 0 && (
|
||||||
<div className="git-mount-list" style={{ display: "flex", flexDirection: "column", gap: "0.75rem", marginBottom: "1rem" }}>
|
<div
|
||||||
{normalizedMounts.map((mount, index) => (
|
className="git-mount-list"
|
||||||
<div key={index} className="card" style={{ padding: "1rem" }}>
|
style={{
|
||||||
{editingIndex === index ? (
|
display: "flex",
|
||||||
<GitMountForm
|
flexDirection: "column",
|
||||||
mount={mount}
|
gap: "0.75rem",
|
||||||
onSave={(updated) => handleUpdate(index, updated)}
|
marginBottom: "1rem",
|
||||||
onCancel={() => setEditingIndex(null)}
|
}}
|
||||||
/>
|
>
|
||||||
) : (
|
{normalizedMounts.map((mount, index) => (
|
||||||
<div>
|
<div key={index} className="card" style={{ padding: "1rem" }}>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "0.5rem" }}>
|
{editingIndex === index ? (
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<GitMountForm
|
||||||
<div style={{ fontWeight: 600, fontSize: "0.9375rem", marginBottom: "0.25rem" }}>
|
mount={mount}
|
||||||
{mount.remote_url}
|
onSave={(updated) => handleUpdate(index, updated)}
|
||||||
{mount.branch && (
|
onCancel={() => setEditingIndex(null)}
|
||||||
<span style={{ color: "var(--muted)", fontWeight: 400, marginLeft: "0.5rem" }}>
|
/>
|
||||||
@{mount.branch}
|
) : (
|
||||||
</span>
|
<div>
|
||||||
)}
|
<div
|
||||||
</div>
|
style={{
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
display: "flex",
|
||||||
{mount.mappings?.map((m, mi) => (
|
justifyContent: "space-between",
|
||||||
<div key={mi} style={{ fontSize: "0.875rem", color: "var(--muted)", fontFamily: "monospace" }}>
|
alignItems: "flex-start",
|
||||||
{m.source_path || "."} → {m.target_path}
|
marginBottom: "0.5rem",
|
||||||
</div>
|
}}
|
||||||
))}
|
>
|
||||||
</div>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
</div>
|
<div
|
||||||
<div style={{ display: "flex", gap: "0.25rem", flexShrink: 0 }}>
|
style={{
|
||||||
<button
|
fontWeight: 600,
|
||||||
type="button"
|
fontSize: "0.9375rem",
|
||||||
className="ghost-button small"
|
marginBottom: "0.25rem",
|
||||||
onClick={() => setEditingIndex(index)}
|
}}
|
||||||
title="Edit"
|
>
|
||||||
>
|
{mount.remote_url}
|
||||||
<Icon name="edit" size="sm" />
|
{mount.branch && (
|
||||||
</button>
|
<span
|
||||||
<button
|
style={{
|
||||||
type="button"
|
color: "var(--muted)",
|
||||||
className="ghost-button small"
|
fontWeight: 400,
|
||||||
onClick={() => handleRemove(index)}
|
marginLeft: "0.5rem",
|
||||||
title="Remove"
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="delete" size="sm" />
|
@{mount.branch}
|
||||||
</button>
|
</span>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
)}
|
style={{
|
||||||
</div>
|
display: "flex",
|
||||||
))}
|
flexDirection: "column",
|
||||||
</div>
|
gap: "0.25rem",
|
||||||
)}
|
}}
|
||||||
|
>
|
||||||
|
{mount.mappings?.map((m, mi) => (
|
||||||
|
<div
|
||||||
|
key={mi}
|
||||||
|
style={{
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.source_path || "."} → {m.target_path}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", gap: "0.25rem", flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => setEditingIndex(index)}
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => handleRemove(index)}
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isAdding ? (
|
{isAdding ? (
|
||||||
<div className="card" style={{ padding: "1rem" }}>
|
<div className="card" style={{ padding: "1rem" }}>
|
||||||
<GitMountForm
|
<GitMountForm
|
||||||
mount={{ remote_url: "", branch: "", mappings: [{ source_path: ".", target_path: "" }] }}
|
mount={{
|
||||||
onSave={handleAdd}
|
remote_url: "",
|
||||||
onCancel={() => setIsAdding(false)}
|
branch: "",
|
||||||
/>
|
mappings: [{ source_path: ".", target_path: "" }],
|
||||||
</div>
|
}}
|
||||||
) : (
|
onSave={handleAdd}
|
||||||
<button type="button" className="secondary-button" onClick={() => setIsAdding(true)}>
|
onCancel={() => setIsAdding(false)}
|
||||||
<Icon name="add" size="sm" />
|
/>
|
||||||
Add Git Mount
|
</div>
|
||||||
</button>
|
) : (
|
||||||
)}
|
<button
|
||||||
</div>
|
type="button"
|
||||||
);
|
className="secondary-button"
|
||||||
|
onClick={() => setIsAdding(true)}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Add Git Mount
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface GitMountFormProps {
|
interface GitMountFormProps {
|
||||||
mount: GitMount;
|
mount: GitMount;
|
||||||
onSave: (mount: GitMount) => void;
|
onSave: (mount: GitMount) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
||||||
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
|
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
|
||||||
const [branch, setBranch] = useState(mount.branch || "");
|
const [branch, setBranch] = useState(mount.branch || "");
|
||||||
const [mappings, setMappings] = useState<GitMountMapping[]>(
|
const [mappings, setMappings] = useState<GitMountMapping[]>(
|
||||||
mount.mappings?.length ? mount.mappings : [{ source_path: ".", target_path: "" }]
|
mount.mappings?.length
|
||||||
);
|
? mount.mappings
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
: [{ source_path: ".", target_path: "" }],
|
||||||
|
);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const validate = (): boolean => {
|
const validate = (): boolean => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
if (!remoteUrl.trim()) {
|
if (!remoteUrl.trim()) {
|
||||||
newErrors.remote_url = "Git URL is required";
|
newErrors.remote_url = "Git URL is required";
|
||||||
} else if (
|
} else if (
|
||||||
!remoteUrl.startsWith("http://") &&
|
!remoteUrl.startsWith("http://") &&
|
||||||
!remoteUrl.startsWith("https://") &&
|
!remoteUrl.startsWith("https://") &&
|
||||||
!remoteUrl.startsWith("git@") &&
|
!remoteUrl.startsWith("git@") &&
|
||||||
!remoteUrl.startsWith("ssh://")
|
!remoteUrl.startsWith("ssh://")
|
||||||
) {
|
) {
|
||||||
newErrors.remote_url = "Must be a valid git URL (https://, git@, or ssh://)";
|
newErrors.remote_url =
|
||||||
}
|
"Must be a valid git URL (https://, git@, or ssh://)";
|
||||||
|
}
|
||||||
|
|
||||||
mappings.forEach((m, i) => {
|
mappings.forEach((m, i) => {
|
||||||
if (!m.target_path.trim()) {
|
if (!m.target_path.trim()) {
|
||||||
newErrors[`mapping_${i}_target`] = "Target path is required";
|
newErrors[`mapping_${i}_target`] = "Target path is required";
|
||||||
}
|
}
|
||||||
if (m.source_path.includes("..")) {
|
if (m.source_path.includes("..")) {
|
||||||
newErrors[`mapping_${i}_source`] = "Source path cannot contain ..";
|
newErrors[`mapping_${i}_source`] = "Source path cannot contain ..";
|
||||||
}
|
}
|
||||||
if (m.target_path.includes("..")) {
|
if (m.target_path.includes("..")) {
|
||||||
newErrors[`mapping_${i}_target`] = "Target path cannot contain ..";
|
newErrors[`mapping_${i}_target`] = "Target path cannot contain ..";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!validate()) return;
|
if (!validate()) return;
|
||||||
onSave({
|
onSave({
|
||||||
remote_url: remoteUrl.trim(),
|
remote_url: remoteUrl.trim(),
|
||||||
branch: branch.trim() || undefined,
|
branch: branch.trim() || undefined,
|
||||||
mappings: mappings.map((m) => ({
|
mappings: mappings.map((m) => ({
|
||||||
source_path: m.source_path.trim() || ".",
|
source_path: m.source_path.trim() || ".",
|
||||||
target_path: m.target_path.trim(),
|
target_path: m.target_path.trim(),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMapping = () => {
|
const addMapping = () => {
|
||||||
setMappings((prev) => [...prev, { source_path: ".", target_path: "" }]);
|
setMappings((prev) => [...prev, { source_path: ".", target_path: "" }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateMapping = (index: number, field: keyof GitMountMapping, value: string) => {
|
const updateMapping = (
|
||||||
setMappings((prev) => {
|
index: number,
|
||||||
const next = [...prev];
|
field: keyof GitMountMapping,
|
||||||
next[index] = { ...next[index], [field]: value };
|
value: string,
|
||||||
return next;
|
) => {
|
||||||
});
|
setMappings((prev) => {
|
||||||
if (errors[`mapping_${index}_${field}`]) {
|
const next = [...prev];
|
||||||
setErrors((prev) => {
|
next[index] = { ...next[index], [field]: value };
|
||||||
const next = { ...prev };
|
return next;
|
||||||
delete next[`mapping_${index}_${field}`];
|
});
|
||||||
return next;
|
if (errors[`mapping_${index}_${field}`]) {
|
||||||
});
|
setErrors((prev) => {
|
||||||
}
|
const next = { ...prev };
|
||||||
};
|
delete next[`mapping_${index}_${field}`];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const removeMapping = (index: number) => {
|
const removeMapping = (index: number) => {
|
||||||
setMappings((prev) => prev.filter((_, i) => i !== index));
|
setMappings((prev) => prev.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||||
<div className="form-row" style={{ gap: "0.5rem" }}>
|
<div className="form-row" style={{ gap: "0.5rem" }}>
|
||||||
<div style={{ flex: 2 }}>
|
<div style={{ flex: 2 }}>
|
||||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>Repository URL</label>
|
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||||
<input
|
Repository URL
|
||||||
type="text"
|
</label>
|
||||||
value={remoteUrl}
|
<input
|
||||||
onChange={(e) => {
|
type="text"
|
||||||
setRemoteUrl(e.target.value);
|
value={remoteUrl}
|
||||||
if (errors.remote_url) {
|
onChange={(e) => {
|
||||||
setErrors((prev) => {
|
setRemoteUrl(e.target.value);
|
||||||
const next = { ...prev };
|
if (errors.remote_url) {
|
||||||
delete next.remote_url;
|
setErrors((prev) => {
|
||||||
return next;
|
const next = { ...prev };
|
||||||
});
|
delete next.remote_url;
|
||||||
}
|
return next;
|
||||||
}}
|
});
|
||||||
placeholder="https://github.com/user/repo.git"
|
}
|
||||||
className={`form-input ${errors.remote_url ? "error" : ""}`}
|
}}
|
||||||
/>
|
placeholder="https://github.com/user/repo.git"
|
||||||
{errors.remote_url && <span className="error-text">{errors.remote_url}</span>}
|
className={`form-input ${errors.remote_url ? "error" : ""}`}
|
||||||
</div>
|
/>
|
||||||
<div style={{ flex: 1 }}>
|
{errors.remote_url && (
|
||||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>Branch (optional)</label>
|
<span className="error-text">{errors.remote_url}</span>
|
||||||
<input
|
)}
|
||||||
type="text"
|
</div>
|
||||||
value={branch}
|
<div style={{ flex: 1 }}>
|
||||||
onChange={(e) => setBranch(e.target.value)}
|
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||||
placeholder="main"
|
Branch (optional)
|
||||||
className="form-input"
|
</label>
|
||||||
/>
|
<input
|
||||||
</div>
|
type="text"
|
||||||
</div>
|
value={branch}
|
||||||
|
onChange={(e) => setBranch(e.target.value)}
|
||||||
|
placeholder="main"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>Mappings</label>
|
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||||
<p className="muted" style={{ margin: "0 0 0.5rem 0", fontSize: "0.8125rem" }}>
|
Mappings
|
||||||
Source paths within the repo and where to mount them in the container.
|
</label>
|
||||||
</p>
|
<p
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
className="muted"
|
||||||
{mappings.map((mapping, index) => (
|
style={{ margin: "0 0 0.5rem 0", fontSize: "0.8125rem" }}
|
||||||
<div key={index} className="form-row" style={{ gap: "0.5rem", alignItems: "flex-start" }}>
|
>
|
||||||
<input
|
Source paths within the repo and where to mount them in the container.
|
||||||
type="text"
|
</p>
|
||||||
value={mapping.source_path}
|
<div
|
||||||
onChange={(e) => updateMapping(index, "source_path", e.target.value)}
|
style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
|
||||||
placeholder="packages/api"
|
>
|
||||||
className={`form-input ${errors[`mapping_${index}_source`] ? "error" : ""}`}
|
{mappings.map((mapping, index) => (
|
||||||
style={{ flex: 1 }}
|
<div
|
||||||
/>
|
key={index}
|
||||||
<span style={{ padding: "0.5rem 0", color: "var(--muted)", fontSize: "0.875rem" }}>→</span>
|
className="form-row"
|
||||||
<input
|
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
||||||
type="text"
|
>
|
||||||
value={mapping.target_path}
|
<input
|
||||||
onChange={(e) => updateMapping(index, "target_path", e.target.value)}
|
type="text"
|
||||||
placeholder="/app/api"
|
value={mapping.source_path}
|
||||||
className={`form-input ${errors[`mapping_${index}_target`] ? "error" : ""}`}
|
onChange={(e) =>
|
||||||
style={{ flex: 1 }}
|
updateMapping(index, "source_path", e.target.value)
|
||||||
/>
|
}
|
||||||
{mappings.length > 1 && (
|
placeholder="packages/api"
|
||||||
<button
|
className={`form-input ${errors[`mapping_${index}_source`] ? "error" : ""}`}
|
||||||
type="button"
|
style={{ flex: 1 }}
|
||||||
className="ghost-button small"
|
/>
|
||||||
onClick={() => removeMapping(index)}
|
<span
|
||||||
title="Remove mapping"
|
style={{
|
||||||
>
|
padding: "0.5rem 0",
|
||||||
<Icon name="delete" size="sm" />
|
color: "var(--muted)",
|
||||||
</button>
|
fontSize: "0.875rem",
|
||||||
)}
|
}}
|
||||||
{errors[`mapping_${index}_source`] && (
|
>
|
||||||
<span className="error-text">{errors[`mapping_${index}_source`]}</span>
|
→
|
||||||
)}
|
</span>
|
||||||
{errors[`mapping_${index}_target`] && (
|
<input
|
||||||
<span className="error-text">{errors[`mapping_${index}_target`]}</span>
|
type="text"
|
||||||
)}
|
value={mapping.target_path}
|
||||||
</div>
|
onChange={(e) =>
|
||||||
))}
|
updateMapping(index, "target_path", e.target.value)
|
||||||
</div>
|
}
|
||||||
<button type="button" className="secondary-button small" onClick={addMapping} style={{ marginTop: "0.5rem" }}>
|
placeholder="/app/api"
|
||||||
<Icon name="add" size="sm" />
|
className={`form-input ${errors[`mapping_${index}_target`] ? "error" : ""}`}
|
||||||
Add Mapping
|
style={{ flex: 1 }}
|
||||||
</button>
|
/>
|
||||||
</div>
|
{mappings.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => removeMapping(index)}
|
||||||
|
title="Remove mapping"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{errors[`mapping_${index}_source`] && (
|
||||||
|
<span className="error-text">
|
||||||
|
{errors[`mapping_${index}_source`]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{errors[`mapping_${index}_target`] && (
|
||||||
|
<span className="error-text">
|
||||||
|
{errors[`mapping_${index}_target`]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={addMapping}
|
||||||
|
style={{ marginTop: "0.5rem" }}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Add Mapping
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-actions" style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
|
<div
|
||||||
<button type="button" className="primary-button" onClick={handleSubmit}>
|
className="form-actions"
|
||||||
Save
|
style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}
|
||||||
</button>
|
>
|
||||||
<button type="button" className="secondary-button" onClick={onCancel}>
|
<button type="button" className="primary-button" onClick={handleSubmit}>
|
||||||
Cancel
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button type="button" className="secondary-button" onClick={onCancel}>
|
||||||
</div>
|
Cancel
|
||||||
);
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
name: home-path-expansion
|
||||||
|
status: completed
|
||||||
|
phase: verify
|
||||||
|
type: feature
|
||||||
|
description: Resolve ~ and $HOME in mount target paths to the container's correct home directory based on manifest user configuration.
|
||||||
|
created_at: 2026-05-28
|
||||||
|
updated_at: 2026-05-28
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Design: ~ / $HOME Expansion in Mount Paths
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### New Helpers
|
||||||
|
|
||||||
|
#### `expand_container_path(path: str, home_dir: str) -> str`
|
||||||
|
Located in `config_profile_resolver.py` (or new shared module).
|
||||||
|
|
||||||
|
```python
|
||||||
|
def expand_container_path(path: str, home_dir: str) -> str:
|
||||||
|
if path.startswith("~/"):
|
||||||
|
return os.path.join(home_dir, path[2:])
|
||||||
|
if path == "~":
|
||||||
|
return home_dir
|
||||||
|
path = path.replace("$HOME/", home_dir + "/")
|
||||||
|
path = path.replace("$HOME", home_dir)
|
||||||
|
return path
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `get_manifest_home_dir(manifest: dict) -> str`
|
||||||
|
Located in `manifest_compiler.py`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_manifest_home_dir(manifest: dict) -> str:
|
||||||
|
user = manifest.get("user")
|
||||||
|
if user and user.get("name"):
|
||||||
|
return f"/home/{user['name']}"
|
||||||
|
return "/root"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `get_tool_home_dir(tool_type: ToolType, manifest: dict | None) -> str`
|
||||||
|
Located in `tool_instances.py` or `manifest_compiler.py`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_tool_home_dir(tool_type: ToolType, manifest: dict | None = None) -> str:
|
||||||
|
if tool_type.definition_type == "manifest" and manifest:
|
||||||
|
return get_manifest_home_dir(manifest)
|
||||||
|
return "/root"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pipeline Changes
|
||||||
|
|
||||||
|
#### `create_instance` flow
|
||||||
|
|
||||||
|
1. Determine `home_dir` from tool type + manifest (if manifest-based)
|
||||||
|
2. Pass `home_dir` to `_modify_compose_file()` — expand mount targets in compose
|
||||||
|
|
||||||
|
#### `start_instance` flow
|
||||||
|
|
||||||
|
1. Determine `home_dir` from tool type + resolved manifest
|
||||||
|
2. Pass `home_dir` to `apply_resolved_profile()` — expand profile mount targets
|
||||||
|
3. Pass `home_dir` to `_resolve_git_mounts()` — expand git mount mapping targets
|
||||||
|
|
||||||
|
#### `apply_resolved_profile()`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def apply_resolved_profile(
|
||||||
|
instance_dir: str,
|
||||||
|
resolved: ResolvedProfile,
|
||||||
|
home_dir: str = "/root",
|
||||||
|
) -> tuple[...]:
|
||||||
|
...
|
||||||
|
for mount in resolved.mounts.values():
|
||||||
|
target = expand_container_path(mount.target, home_dir)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `_resolve_git_mount_mappings()`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _resolve_git_mount_mappings(
|
||||||
|
repo_path: str,
|
||||||
|
mappings: list[dict],
|
||||||
|
working_directory: str | None,
|
||||||
|
home_dir: str = "/root",
|
||||||
|
) -> list[dict]:
|
||||||
|
...
|
||||||
|
final_target = expand_container_path(target_path, home_dir)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dockerfile Change
|
||||||
|
|
||||||
|
In `compile_dockerfile()`, after user creation, set `HOME`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if user:
|
||||||
|
home = f"/home/{user['name']}"
|
||||||
|
lines.append(f"ENV HOME={home}")
|
||||||
|
lines.append(f"ENV USER={user['name']}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Changes
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `apps/api/src/services/config_profile_resolver.py` | Add `expand_container_path()`, apply in `apply_resolved_profile()` |
|
||||||
|
| `apps/api/src/services/manifest_compiler.py` | Add `get_manifest_home_dir()`, set `HOME`/`USER` env in Dockerfile |
|
||||||
|
| `apps/api/src/api/tool_instances.py` | Determine `home_dir`, pass through all mount resolution functions |
|
||||||
|
| `apps/api/tests/unit/test_home_path_expansion.py` | New unit tests |
|
||||||
|
| `apps/api/tests/unit/test_manifest_compiler.py` | Add tests for `get_manifest_home_dir` and Dockerfile `HOME` env |
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Exploration: ~ / $HOME Expansion in Mount Paths
|
||||||
|
|
||||||
|
## User Request
|
||||||
|
Allow `~` and `$HOME` in mount paths for both regular mounts and git mounts.
|
||||||
|
|
||||||
|
## Where This Applies
|
||||||
|
|
||||||
|
### Container target paths (where it makes sense)
|
||||||
|
- **Regular mounts** (`mount.target`): The absolute path inside the container where files are bind-mounted
|
||||||
|
- **Git mount mappings** (`mapping.target_path`): The absolute path inside the container where repo subdirectories are mounted
|
||||||
|
|
||||||
|
### Where it does NOT apply
|
||||||
|
- **Regular mount file paths** (`mount.files` keys): These are relative to the mount target
|
||||||
|
- **Git mount source paths** (`mapping.source_path`): These are relative to the cloned repo
|
||||||
|
- **Host-side source paths**: The API runs in a container; `~` on the host would mean the Docker host's home, which the API container cannot access
|
||||||
|
|
||||||
|
## Complexity Assessment
|
||||||
|
|
||||||
|
### The Core Problem
|
||||||
|
`~` means "user's home directory". But whose home?
|
||||||
|
|
||||||
|
| Context | Home Directory | Knowable at Mount Time? |
|
||||||
|
|---------|---------------|------------------------|
|
||||||
|
| API container | `/root` or `/app` | Yes |
|
||||||
|
| Target container (manifest, user=root) | `/root` | Yes (from manifest) |
|
||||||
|
| Target container (manifest, user=user) | `/home/user` | Yes (from manifest) |
|
||||||
|
| Target container (legacy tool type) | Unknown | No (assume `/root`) |
|
||||||
|
| Docker host | `/home/alex` or similar | No (API is containerized) |
|
||||||
|
|
||||||
|
### Docker Compose Reality Check
|
||||||
|
Docker Compose **does not expand** `~` or `$HOME` in volume targets. These are passed literally to the Docker daemon. So `~/workspace` becomes a directory literally named `~` in the container root.
|
||||||
|
|
||||||
|
This means **we must resolve the path ourselves** before writing the compose file.
|
||||||
|
|
||||||
|
## Design Options
|
||||||
|
|
||||||
|
### Option A: Simple `/root` default (minimal change)
|
||||||
|
- Replace `~` and `$HOME` with `/root` in all container target paths
|
||||||
|
- Apply during compose modification and git mount resolution
|
||||||
|
- **Effort**: ~30 min, ~20 lines
|
||||||
|
- **Pros**: Dead simple, works for root-running containers (most legacy setups)
|
||||||
|
- **Cons**: Wrong for pi-agent (runs as `user`, home `/home/user`)
|
||||||
|
|
||||||
|
### Option B: Manifest-aware home directory (recommended)
|
||||||
|
- For manifest-based tools: read `user.name` from manifest, compute home as `/home/{name}` or `/root`
|
||||||
|
- For legacy tools: default to `/root`
|
||||||
|
- Pass `home_dir` through the mount resolution pipeline
|
||||||
|
- Apply expansion in `apply_resolved_profile()` and `_resolve_git_mount_mappings()`
|
||||||
|
- **Effort**: ~2 hours, touches 3-4 files
|
||||||
|
- **Pros**: Correct for all container types
|
||||||
|
- **Cons**: Slightly more plumbing
|
||||||
|
|
||||||
|
### Option C: Configurable home per profile
|
||||||
|
- Add `home_directory` field to ConfigProfile
|
||||||
|
- User can override the container home directory
|
||||||
|
- **Effort**: ~3 hours, schema change
|
||||||
|
- **Cons**: Overkill, clutters UI
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
**Option B** — manifest-aware expansion. The pi-agent manifest already declares `user.name`, so we can compute the correct home directory. For backward compatibility, legacy tool types default to `/root`.
|
||||||
|
|
||||||
|
## Files to Touch
|
||||||
|
|
||||||
|
1. `apps/api/src/services/config_profile_resolver.py` — add `expand_container_path()` helper
|
||||||
|
2. `apps/api/src/api/tool_instances.py` — pass `home_dir` to `apply_resolved_profile()` and git mount functions; determine home from manifest/tool type
|
||||||
|
3. `apps/api/src/services/manifest_compiler.py` — expose helper to extract user from manifest
|
||||||
|
4. Tests for expansion logic
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Wrong home for custom containers | Document that manifest should declare `user.name` |
|
||||||
|
| `$HOME` env var not set in container | We resolve it at compose generation time, so no runtime dependency |
|
||||||
|
| Breaking existing profiles with literal `~` in path | Very unlikely; we can add an escape hatch if needed |
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Proposal: ~ / $HOME Expansion in Mount Paths
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Users want to write mount target paths like `~/workspace` or `$HOME/workspace` instead of absolute paths like `/home/user/workspace` or `/root/workspace`. Docker Compose does not expand these — they must be resolved before writing the compose file.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Resolve `~` and `$HOME` in container target paths to the correct home directory for the target container.
|
||||||
|
|
||||||
|
## Direction
|
||||||
|
|
||||||
|
**Manifest-aware home directory (Option B)**
|
||||||
|
- Extract `user.name` from the tool manifest to compute `/home/{name}`
|
||||||
|
- For root-based manifests (no user block), use `/root`
|
||||||
|
- For legacy tool types (non-manifest), default to `/root`
|
||||||
|
- Apply expansion at compose generation time for both regular mounts and git mount targets
|
||||||
|
- Also set `HOME` env var in the Dockerfile for runtime compatibility
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. `~` in a mount target path is expanded to the container's home directory
|
||||||
|
2. `$HOME` in a mount target path is expanded to the container's home directory
|
||||||
|
3. For manifest-based tools with `user.name`, home is `/home/{user.name}`
|
||||||
|
4. For manifest-based tools without user block, home is `/root`
|
||||||
|
5. For legacy tool types, home is `/root`
|
||||||
|
6. Git mount `mapping.target_path` also supports `~` and `$HOME`
|
||||||
|
7. `HOME` env var is set in generated Dockerfile
|
||||||
|
8. No frontend changes needed (users type `~`, backend resolves it)
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- `~` expansion in host-side source paths
|
||||||
|
- `~` expansion in mount file relative paths
|
||||||
|
- `~user` syntax (e.g., `~alice`)
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Wrong home for custom containers | Document that manifest should declare `user.name` |
|
||||||
|
| `$HOME` env var not set in container | Set it in Dockerfile via `ENV HOME=...` |
|
||||||
|
|
||||||
|
## Related Artifacts
|
||||||
|
|
||||||
|
- Exploration: `openspec/explorations/home-path-expansion.md`
|
||||||
|
- Spec: `openspec/specs/home-path-expansion.md`
|
||||||
|
- Design: `openspec/designs/home-path-expansion.md`
|
||||||
|
- Tasks: `openspec/tasks/home-path-expansion.md`
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Spec: ~ / $HOME Expansion in Mount Paths
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional
|
||||||
|
|
||||||
|
1. **FR-1**: `~` in mount target paths MUST be expanded to the container's home directory.
|
||||||
|
2. **FR-2**: `$HOME` in mount target paths MUST be expanded to the container's home directory.
|
||||||
|
3. **FR-3**: For manifest-based tools with `user.name`, home MUST be `/home/{user.name}`.
|
||||||
|
4. **FR-4**: For manifest-based tools without user block, home MUST be `/root`.
|
||||||
|
5. **FR-5**: For legacy tool types, home MUST be `/root`.
|
||||||
|
6. **FR-6**: Git mount `mapping.target_path` MUST support `~` and `$HOME`.
|
||||||
|
7. **FR-7**: The generated Dockerfile MUST set `HOME` env var.
|
||||||
|
|
||||||
|
### Non-Functional
|
||||||
|
|
||||||
|
1. **NFR-1**: No database schema changes.
|
||||||
|
2. **NFR-2**: No frontend changes.
|
||||||
|
3. **NFR-3**: Existing profiles without `~` MUST continue working.
|
||||||
|
|
||||||
|
## API Contracts
|
||||||
|
|
||||||
|
No API changes. Resolution happens server-side during compose generation.
|
||||||
|
|
||||||
|
## Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: pi-agent with ~ mount
|
||||||
|
|
||||||
|
**Given** a Config Profile with:
|
||||||
|
```json
|
||||||
|
{"mounts": [{"target": "~/workspace", "mode": "rw", "files": {}}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**And** a pi-agent manifest with `user.name = "user"`
|
||||||
|
|
||||||
|
**When** the profile is applied
|
||||||
|
|
||||||
|
**Then** the compose file contains `/home/user/workspace` as the mount target.
|
||||||
|
|
||||||
|
### Scenario 2: Legacy tool with $HOME mount
|
||||||
|
|
||||||
|
**Given** a Config Profile with:
|
||||||
|
```json
|
||||||
|
{"mounts": [{"target": "$HOME/config", "mode": "ro", "files": {}}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**And** a legacy tool type (no manifest)
|
||||||
|
|
||||||
|
**When** the profile is applied
|
||||||
|
|
||||||
|
**Then** the compose file contains `/root/config` as the mount target.
|
||||||
|
|
||||||
|
### Scenario 3: Git mount with ~ target
|
||||||
|
|
||||||
|
**Given** a Config Profile with:
|
||||||
|
```json
|
||||||
|
{"git_mounts": [{"remote_url": "...", "mappings": [{"source_path": ".", "target_path": "~/repo"}]}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**And** a manifest with `user.name = "user"`
|
||||||
|
|
||||||
|
**When** the profile is applied
|
||||||
|
|
||||||
|
**Then** the compose file contains `/home/user/repo` as the mount target.
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
1. Unit test `expand_container_path` with `~`, `$HOME`, absolute paths, relative paths
|
||||||
|
2. Unit test `get_manifest_home_dir` with user block, without user block
|
||||||
|
3. Integration test: profile with `~` mount applied to pi-agent instance
|
||||||
|
4. Integration test: profile with `$HOME` mount applied to legacy instance
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Tasks: ~ / $HOME Expansion in Mount Paths
|
||||||
|
|
||||||
|
## T1: Backend — Core helpers and pipeline
|
||||||
|
|
||||||
|
### T1.1: Add `expand_container_path` helper
|
||||||
|
**File**: `apps/api/src/services/config_profile_resolver.py`
|
||||||
|
- Add `expand_container_path(path: str, home_dir: str) -> str`
|
||||||
|
- Handle `~/`, `~`, `$HOME/`, `$HOME` patterns
|
||||||
|
- Must not expand if path doesn't start with these patterns
|
||||||
|
|
||||||
|
### T1.2: Add `get_manifest_home_dir` helper
|
||||||
|
**File**: `apps/api/src/services/manifest_compiler.py`
|
||||||
|
- Add `get_manifest_home_dir(manifest: dict) -> str`
|
||||||
|
- Returns `/home/{user.name}` if user block exists, else `/root`
|
||||||
|
|
||||||
|
### T1.3: Set `HOME` and `USER` env vars in Dockerfile
|
||||||
|
**File**: `apps/api/src/services/manifest_compiler.py`
|
||||||
|
- In `compile_dockerfile()`, after user creation block, add `ENV HOME=...` and `ENV USER=...`
|
||||||
|
- Update existing manifest compiler tests
|
||||||
|
|
||||||
|
### T1.4: Update `apply_resolved_profile` to expand paths
|
||||||
|
**File**: `apps/api/src/services/config_profile_resolver.py`
|
||||||
|
- Add `home_dir: str = "/root"` parameter
|
||||||
|
- Expand mount targets before creating mount directories and volume entries
|
||||||
|
|
||||||
|
### T1.5: Update git mount resolution to expand paths
|
||||||
|
**File**: `apps/api/src/api/tool_instances.py`
|
||||||
|
- Add `home_dir: str = "/root"` parameter to `_resolve_git_mount_mappings()`
|
||||||
|
- Expand mapping target paths before resolving
|
||||||
|
- Add `home_dir` parameter to `_resolve_git_mounts()` and `_resolve_single_git_mount()`
|
||||||
|
|
||||||
|
### T1.6: Determine home_dir in instance lifecycle
|
||||||
|
**File**: `apps/api/src/api/tool_instances.py`
|
||||||
|
- In `create_instance`: determine `home_dir` from tool type + manifest (if manifest), pass to `_modify_compose_file`
|
||||||
|
- In `start_instance`: determine `home_dir` from tool type + resolved manifest, pass to `apply_resolved_profile` and `_resolve_git_mounts`
|
||||||
|
- In `_prepare_manifest_instance`: return `home_dir` alongside image_tag and compose_content
|
||||||
|
|
||||||
|
### T1.7: Update `_modify_compose_file` to expand paths
|
||||||
|
**File**: `apps/api/src/api/tool_instances.py`
|
||||||
|
- Add `home_dir: str = "/root"` parameter
|
||||||
|
- Expand `working_directory` and any mount targets in extra_volumes
|
||||||
|
|
||||||
|
### T1.8: Unit tests
|
||||||
|
**File**: `apps/api/tests/unit/test_home_path_expansion.py` (new)
|
||||||
|
- Test `expand_container_path` with `~`, `~/foo`, `$HOME`, `$HOME/foo`, `/abs/path`, `rel/path`
|
||||||
|
- Test `get_manifest_home_dir` with user, without user
|
||||||
|
|
||||||
|
**File**: `apps/api/tests/unit/test_manifest_compiler.py`
|
||||||
|
- Test Dockerfile contains `ENV HOME=...` for user-based manifests
|
||||||
|
- Test Dockerfile contains `ENV HOME=/root` for root manifests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T2: Verification
|
||||||
|
|
||||||
|
### T2.1: Run all affected tests
|
||||||
|
```bash
|
||||||
|
cd apps/api && pytest tests/unit/test_home_path_expansion.py tests/unit/test_manifest_compiler.py tests/unit/test_config_profile_resolver.py tests/unit/test_git_mounts.py -xvs
|
||||||
|
```
|
||||||
|
|
||||||
|
### T2.2: Frontend typecheck
|
||||||
|
```bash
|
||||||
|
cd apps/web && npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
| Task | Effort | Files |
|
||||||
|
|------|--------|-------|
|
||||||
|
| T1.1-T1.7 | 2h | 3 |
|
||||||
|
| T1.8 | 1h | 2 |
|
||||||
|
| T2.1-T2.2 | 0.5h | — |
|
||||||
|
| **Total** | **3.5h** | **5** |
|
||||||
Reference in New Issue
Block a user