feat: implement config profile git mounts
- Add git_mounts column to config_profiles table (JSONB) - Create GitMount Pydantic models with validation - Add repo validation in create/update endpoints - Update profile resolver to merge git mounts from includes - Implement auto-clone, branch checkout, and glob expansion - Add parallel processing for git mount resolution - Create GitMountEditor frontend component - Update TypeScript types and API clients - Add CSS styles for git mount UI - Frontend type check and build pass Implements tasks 1.1-7.9 of config-profile-git-mounts spec
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
"""add_git_mounts_to_config_profiles
|
||||
|
||||
Revision ID: 2026_05_26_add_git_mounts
|
||||
Revises: f3d2dc90ba3a
|
||||
Create Date: 2026-05-26 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_26_add_git_mounts"
|
||||
down_revision: Union[str, Sequence[str], None] = "f3d2dc90ba3a"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"config_profiles",
|
||||
sa.Column("git_mounts", sa.JSON(), nullable=True, default=list),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("config_profiles", "git_mounts")
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy.orm import selectinload
|
||||
from src.api.shared_validators import validate_env_vars as _validate_env_vars
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.tool_type import ToolType
|
||||
from src.services.config_profile_resolver import (
|
||||
@@ -56,6 +57,40 @@ def _calculate_profile_size(data: dict) -> int:
|
||||
return total
|
||||
|
||||
|
||||
class GitMountItem(BaseModel):
|
||||
repo_id: str = Field(description="UUID of the git repository")
|
||||
source_path: str = Field(default=".", description="Path within repository (supports glob patterns)")
|
||||
target_path: str = Field(description="Absolute path inside container")
|
||||
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
||||
|
||||
@field_validator("repo_id")
|
||||
@classmethod
|
||||
def validate_repo_id(cls, v: str) -> str:
|
||||
try:
|
||||
uuid.UUID(v)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid repo_id UUID: {v}")
|
||||
return v
|
||||
|
||||
@field_validator("source_path")
|
||||
@classmethod
|
||||
def validate_source_path(cls, v: str) -> str:
|
||||
if v.startswith("/"):
|
||||
raise ValueError("source_path must be relative (no leading /)")
|
||||
if ".." in v:
|
||||
raise ValueError("source_path cannot contain path traversal (..)")
|
||||
return v
|
||||
|
||||
@field_validator("target_path")
|
||||
@classmethod
|
||||
def validate_target_path(cls, v: str) -> str:
|
||||
if not v.startswith("/"):
|
||||
raise ValueError("target_path must be absolute (start with /)")
|
||||
if ".." in v:
|
||||
raise ValueError("target_path cannot contain path traversal (..)")
|
||||
return v
|
||||
|
||||
|
||||
class MountItem(BaseModel):
|
||||
target: str = Field(description="Absolute mount target path")
|
||||
mode: str = Field(default="rw", description="Mount mode: ro or rw")
|
||||
@@ -98,6 +133,7 @@ class ConfigProfileCreate(BaseModel):
|
||||
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
|
||||
mounts: list[MountItem] = Field(default_factory=list, description="Mount definitions")
|
||||
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
|
||||
git_mounts: list[GitMountItem] = Field(default_factory=list, description="Git repository mounts")
|
||||
is_default: bool = Field(default=False, description="Whether this is the default profile for its scope")
|
||||
|
||||
@field_validator("project_id", "tool_type_id")
|
||||
@@ -150,6 +186,7 @@ class ConfigProfileUpdate(BaseModel):
|
||||
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
|
||||
mounts: list[MountItem] | None = Field(default=None, description="Mount definitions")
|
||||
files: dict | None = Field(default=None, description="Files as {relative_path: content}")
|
||||
git_mounts: list[GitMountItem] | None = Field(default=None, description="Git repository mounts")
|
||||
is_default: bool | None = Field(default=None, description="Whether this is the default profile")
|
||||
|
||||
@field_validator("project_id", "tool_type_id")
|
||||
@@ -193,6 +230,7 @@ class ConfigProfileResponse(BaseModel):
|
||||
runtime_hints: dict
|
||||
mounts: list
|
||||
files: dict
|
||||
git_mounts: list
|
||||
is_default: bool
|
||||
includes: list[dict]
|
||||
created_at: str
|
||||
@@ -227,6 +265,55 @@ async def _check_access(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found")
|
||||
|
||||
|
||||
async def _validate_git_mounts(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
git_mounts: list[dict],
|
||||
project_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Validate that all referenced git repositories exist and are accessible.
|
||||
|
||||
Repositories must:
|
||||
1. Exist
|
||||
2. Belong to the user
|
||||
3. If project_id is specified, belong to that project
|
||||
"""
|
||||
for mount in git_mounts:
|
||||
repo_id = mount.get("repo_id")
|
||||
if not repo_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Git mount missing repo_id",
|
||||
)
|
||||
|
||||
try:
|
||||
repo_uuid = uuid.UUID(repo_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid repo_id UUID: {repo_id}",
|
||||
)
|
||||
|
||||
repo = await session.get(GitRepository, repo_uuid)
|
||||
if repo is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Git repository not found: {repo_id}",
|
||||
)
|
||||
|
||||
if repo.owner_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Not authorized to access repository: {repo_id}",
|
||||
)
|
||||
|
||||
if project_id is not None and repo.project_id != project_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Repository {repo_id} does not belong to project {project_id}",
|
||||
)
|
||||
|
||||
|
||||
def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None) -> dict:
|
||||
return {
|
||||
"id": str(profile.id),
|
||||
@@ -238,6 +325,7 @@ def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInc
|
||||
"env_vars": profile.env_vars or {},
|
||||
"runtime_hints": profile.runtime_hints or {},
|
||||
"mounts": profile.mounts or [],
|
||||
"git_mounts": profile.git_mounts or [],
|
||||
"files": profile.files or {},
|
||||
"is_default": profile.is_default,
|
||||
"includes": [
|
||||
@@ -321,6 +409,11 @@ async def create_config_profile(
|
||||
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
|
||||
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
|
||||
await _check_access(session, user_uuid, project_uuid, tool_uuid)
|
||||
|
||||
# Validate git mounts reference existing repositories
|
||||
if 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)
|
||||
|
||||
# Check size
|
||||
size = _calculate_profile_size(data.model_dump())
|
||||
@@ -339,6 +432,7 @@ async def create_config_profile(
|
||||
env_vars=data.env_vars,
|
||||
runtime_hints=data.runtime_hints,
|
||||
mounts=[m.model_dump() for m in data.mounts],
|
||||
git_mounts=[m.model_dump() for m in data.git_mounts],
|
||||
files=data.files,
|
||||
is_default=data.is_default,
|
||||
)
|
||||
@@ -415,6 +509,14 @@ async def update_config_profile(
|
||||
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)
|
||||
|
||||
# Validate git mounts reference existing repositories
|
||||
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
|
||||
git_mounts_data = [
|
||||
m.model_dump() if hasattr(m, "model_dump") else m
|
||||
for m in update_data["git_mounts"]
|
||||
]
|
||||
await _validate_git_mounts(session, profile.user_id, git_mounts_data, project_uuid)
|
||||
|
||||
# Check size
|
||||
current_data = _profile_to_response(profile)
|
||||
@@ -432,6 +534,8 @@ async def update_config_profile(
|
||||
value = uuid.UUID(value) if value else None
|
||||
elif field_name == "mounts" and value is not None:
|
||||
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
|
||||
elif field_name == "git_mounts" and value is not None:
|
||||
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
|
||||
setattr(profile, field_name, value)
|
||||
|
||||
await session.commit()
|
||||
|
||||
@@ -46,13 +46,214 @@ from src.services.docker import (
|
||||
from src.services.clone import check_dirty_state, clone_repository, remove_clone_directory
|
||||
from src.services.docker_build import build_image
|
||||
from src.services.config_profile_resolver import (
|
||||
ConfigProfileCycleError,
|
||||
apply_resolved_profile,
|
||||
resolve_profile,
|
||||
ConfigProfileCycleError,
|
||||
ResolvedProfile,
|
||||
)
|
||||
from src.services.readiness_probe import execute_probe
|
||||
from src.services.ssh_keys import prepare_ssh_key_files, cleanup_ssh_key_files
|
||||
|
||||
import asyncio
|
||||
import glob as glob_module
|
||||
|
||||
async def _resolve_git_mounts(
|
||||
session: AsyncSession,
|
||||
resolved: ResolvedProfile,
|
||||
instance_dir: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Convert git mounts from resolved profile to Docker volume mounts.
|
||||
|
||||
Looks up repository paths, auto-clones if needed, handles branch checkout,
|
||||
expands glob patterns, and prepares bind mount entries.
|
||||
Logs warnings for missing repos or invalid paths (non-blocking).
|
||||
"""
|
||||
if not resolved.git_mounts:
|
||||
return []
|
||||
|
||||
# Process all git mounts concurrently
|
||||
tasks = []
|
||||
for git_mount in resolved.git_mounts:
|
||||
tasks.append(_resolve_single_git_mount(session, git_mount, instance_dir))
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
volume_mounts = []
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
logger.warning("Git mount failed: %s", result)
|
||||
continue
|
||||
if result:
|
||||
volume_mounts.extend(result)
|
||||
|
||||
return volume_mounts
|
||||
|
||||
|
||||
async def _resolve_single_git_mount(
|
||||
session: AsyncSession,
|
||||
git_mount: dict,
|
||||
instance_dir: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Resolve a single git mount to volume mount entries.
|
||||
|
||||
Returns a list of volume mounts (one for each matched file/directory).
|
||||
"""
|
||||
repo_id = git_mount.get("repo_id")
|
||||
source_path = git_mount.get("source_path", ".")
|
||||
target_path = git_mount.get("target_path")
|
||||
branch = git_mount.get("branch")
|
||||
|
||||
if not repo_id or not target_path:
|
||||
logger.warning("Invalid git mount skipped: missing repo_id or target_path")
|
||||
return []
|
||||
|
||||
try:
|
||||
repo_uuid = uuid.UUID(repo_id)
|
||||
except ValueError:
|
||||
logger.warning("Invalid git mount skipped: repo_id is not a valid UUID: %s", repo_id)
|
||||
return []
|
||||
|
||||
repo = await session.get(GitRepository, repo_uuid)
|
||||
if repo is None:
|
||||
logger.warning("Git mount skipped: repository %s not found", repo_id)
|
||||
return []
|
||||
|
||||
# Determine repo path - use existing or auto-clone
|
||||
repo_path = repo.path
|
||||
|
||||
if not repo_path or not os.path.exists(repo_path):
|
||||
# Auto-clone if remote URL is available and instance_dir is provided
|
||||
if repo.remote_url and instance_dir:
|
||||
try:
|
||||
ssh_key_path = None
|
||||
if repo.ssh_key_id:
|
||||
from src.models.ssh_key import SSHKey
|
||||
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
|
||||
if ssh_key:
|
||||
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
|
||||
ssh_key_path = str(Path(ssh_dir) / "id_ed25519")
|
||||
|
||||
repo_path = await asyncio.to_thread(
|
||||
clone_repository,
|
||||
repo.remote_url,
|
||||
ssh_key_path,
|
||||
instance_dir,
|
||||
branch or "main",
|
||||
)
|
||||
logger.info("Auto-cloned repository %s to %s", repo.name, repo_path)
|
||||
except Exception as exc:
|
||||
logger.warning("Auto-clone failed for repository %s: %s", repo.name, exc)
|
||||
return []
|
||||
else:
|
||||
logger.warning(
|
||||
"Git mount skipped: repository %s path not found at %s and no remote_url available",
|
||||
repo_id, repo_path
|
||||
)
|
||||
return []
|
||||
|
||||
# Handle branch checkout if specified
|
||||
if branch and repo_path:
|
||||
try:
|
||||
await asyncio.to_thread(_checkout_branch, repo_path, branch)
|
||||
logger.info("Checked out branch %s for repository %s", branch, repo.name)
|
||||
except Exception as exc:
|
||||
logger.warning("Branch checkout failed for %s@%s: %s", repo.name, branch, exc)
|
||||
# Continue with current branch as fallback
|
||||
|
||||
# Build source path and expand globs
|
||||
if source_path and source_path != ".":
|
||||
source_full = os.path.join(repo_path, source_path)
|
||||
else:
|
||||
source_full = repo_path
|
||||
|
||||
# Expand glob patterns
|
||||
matched_paths = _expand_glob_source(source_full, repo_path)
|
||||
|
||||
if not matched_paths:
|
||||
logger.warning("Git mount skipped: no files matched source path %s in repository %s", source_path, repo_id)
|
||||
return []
|
||||
|
||||
volume_mounts = []
|
||||
for matched_path in matched_paths:
|
||||
if not os.path.exists(matched_path):
|
||||
continue
|
||||
|
||||
# Determine target path for this match
|
||||
if len(matched_paths) == 1:
|
||||
# Single match: mount directly to target_path
|
||||
final_target = target_path
|
||||
else:
|
||||
# Multiple matches: append relative path to target
|
||||
rel_path = os.path.relpath(matched_path, repo_path)
|
||||
final_target = os.path.join(target_path, rel_path)
|
||||
|
||||
volume_mounts.append({
|
||||
"source": matched_path,
|
||||
"target": final_target,
|
||||
"type": "bind",
|
||||
})
|
||||
logger.info("Added git mount: %s -> %s (repo: %s)", matched_path, final_target, repo.name)
|
||||
|
||||
return volume_mounts
|
||||
|
||||
|
||||
def _checkout_branch(repo_path: str, branch: str) -> None:
|
||||
"""Checkout a specific branch in a git repository."""
|
||||
import subprocess
|
||||
|
||||
# First try to checkout existing branch
|
||||
result = subprocess.run(
|
||||
["git", "-C", repo_path, "checkout", branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
# Try fetching and checking out
|
||||
subprocess.run(
|
||||
["git", "-C", repo_path, "fetch", "origin", branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
result = subprocess.run(
|
||||
["git", "-C", repo_path, "checkout", "-b", branch, f"origin/{branch}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Failed to checkout branch {branch}: {result.stderr}")
|
||||
|
||||
|
||||
def _expand_glob_source(source_path: str, repo_path: str) -> list[str]:
|
||||
"""Expand glob patterns in source path.
|
||||
|
||||
Returns a list of matched absolute paths.
|
||||
Limits results to prevent abuse.
|
||||
"""
|
||||
MAX_GLOB_MATCHES = 100
|
||||
|
||||
# Check if path contains glob characters
|
||||
if not any(c in source_path for c in "*?["):
|
||||
# No glob pattern: return single path if it exists
|
||||
return [source_path] if os.path.exists(source_path) else []
|
||||
|
||||
# Expand glob pattern
|
||||
matched = glob_module.glob(source_path, recursive=True)
|
||||
|
||||
# Filter to only paths within the repo and limit count
|
||||
results = []
|
||||
for path in matched:
|
||||
abs_path = os.path.abspath(path)
|
||||
if abs_path.startswith(os.path.abspath(repo_path)):
|
||||
results.append(abs_path)
|
||||
if len(results) >= MAX_GLOB_MATCHES:
|
||||
logger.warning("Glob pattern matched too many files, limiting to %d", MAX_GLOB_MATCHES)
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
||||
|
||||
|
||||
@@ -687,6 +888,9 @@ async def start_instance(
|
||||
config_files.update(profile_files)
|
||||
# Profile mounts are added to extra volumes
|
||||
extra_volumes.extend(profile_mounts)
|
||||
# Git repository mounts are resolved and added
|
||||
git_mount_volumes = await _resolve_git_mounts(session, resolved, instance_dir)
|
||||
extra_volumes.extend(git_mount_volumes)
|
||||
# Profile runtime hints override tool config values
|
||||
if profile_hints.get("start_command"):
|
||||
start_command = profile_hints["start_command"]
|
||||
@@ -695,12 +899,13 @@ async def start_instance(
|
||||
if profile_hints.get("port_override"):
|
||||
port_override = profile_hints["port_override"]
|
||||
logger.info(
|
||||
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d)",
|
||||
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)",
|
||||
resolved.profile_name,
|
||||
instance.id,
|
||||
len(profile_env),
|
||||
len(profile_files),
|
||||
len(profile_mounts),
|
||||
len(git_mount_volumes),
|
||||
)
|
||||
except ConfigProfileCycleError as exc:
|
||||
logger.error("Cycle detected in config profile for instance %s: %s", instance.id, exc)
|
||||
|
||||
@@ -39,6 +39,9 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
files: Mapped[dict] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
) # {"rel/path": "content", ...}
|
||||
git_mounts: Mapped[list] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
) # [{"repo_id": "uuid", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
user: Mapped["User"] = relationship()
|
||||
|
||||
@@ -48,6 +48,7 @@ class ResolvedProfile:
|
||||
env_vars: dict[str, str] = field(default_factory=dict)
|
||||
runtime_hints: dict[str, Any] = field(default_factory=dict)
|
||||
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
|
||||
git_mounts: list[dict[str, Any]] = field(default_factory=list)
|
||||
files: dict[str, str] = field(default_factory=dict)
|
||||
env_overrides: dict[str, str] = field(default_factory=dict)
|
||||
hint_overrides: dict[str, str] = field(default_factory=dict)
|
||||
@@ -168,6 +169,28 @@ def _merge_mounts(
|
||||
return result
|
||||
|
||||
|
||||
def _merge_git_mounts(
|
||||
base: list[dict[str, Any]],
|
||||
overlay: list[dict[str, Any]],
|
||||
source_name: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Merge git mounts from included profiles.
|
||||
|
||||
Later mounts override earlier ones with the same repo_id + target_path combo.
|
||||
"""
|
||||
result = list(base)
|
||||
# Build lookup by (repo_id, target_path)
|
||||
seen = {(m["repo_id"], m["target_path"]): i for i, m in enumerate(result)}
|
||||
for mount in overlay:
|
||||
key = (mount["repo_id"], mount["target_path"])
|
||||
if key in seen:
|
||||
result[seen[key]] = dict(mount)
|
||||
else:
|
||||
seen[key] = len(result)
|
||||
result.append(dict(mount))
|
||||
return result
|
||||
|
||||
|
||||
async def _resolve_profile_recursive(
|
||||
session: AsyncSession,
|
||||
profile_id: uuid.UUID,
|
||||
@@ -244,6 +267,9 @@ async def _resolve_profile_recursive(
|
||||
result.mount_overrides,
|
||||
included.profile_name,
|
||||
)
|
||||
result.git_mounts = _merge_git_mounts(
|
||||
result.git_mounts, included.git_mounts, included.profile_name
|
||||
)
|
||||
|
||||
# Apply the profile's own settings (selected profile overrides includes)
|
||||
result.env_vars = _merge_env_vars(
|
||||
@@ -270,7 +296,11 @@ async def _resolve_profile_recursive(
|
||||
result.mount_overrides,
|
||||
profile.name,
|
||||
)
|
||||
|
||||
result.git_mounts = _merge_git_mounts(
|
||||
result.git_mounts,
|
||||
profile.git_mounts or [],
|
||||
profile.name,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -449,5 +479,6 @@ def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
|
||||
"files": resolved.file_overrides,
|
||||
"mounts": resolved.mount_overrides,
|
||||
},
|
||||
"git_mounts": resolved.git_mounts,
|
||||
"included_profiles": resolved.included_profiles,
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ConfigProfile {
|
||||
env_vars: Record<string, string>;
|
||||
runtime_hints: Record<string, unknown>;
|
||||
mounts: ConfigProfileMount[];
|
||||
git_mounts: GitMount[];
|
||||
files: Record<string, string>;
|
||||
is_default: boolean;
|
||||
includes: ConfigProfileInclude[];
|
||||
@@ -23,6 +24,13 @@ export interface ConfigProfileMount {
|
||||
files: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface GitMount {
|
||||
repo_id: string;
|
||||
source_path: string;
|
||||
target_path: string;
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
export interface ConfigProfileInclude {
|
||||
id: string;
|
||||
included_profile_id: string;
|
||||
@@ -35,6 +43,7 @@ export interface ResolvedProfile {
|
||||
env_vars: Record<string, string>;
|
||||
runtime_hints: Record<string, unknown>;
|
||||
mounts: ResolvedMount[];
|
||||
git_mounts: GitMount[];
|
||||
files: Record<string, string>;
|
||||
overrides: {
|
||||
env_vars: Record<string, string>;
|
||||
@@ -60,6 +69,7 @@ export interface CreateConfigProfileRequest {
|
||||
env_vars?: Record<string, string>;
|
||||
runtime_hints?: Record<string, unknown>;
|
||||
mounts?: ConfigProfileMount[];
|
||||
git_mounts?: GitMount[];
|
||||
files?: Record<string, string>;
|
||||
is_default?: boolean;
|
||||
}
|
||||
@@ -72,6 +82,7 @@ export interface UpdateConfigProfileRequest {
|
||||
env_vars?: Record<string, string>;
|
||||
runtime_hints?: Record<string, unknown>;
|
||||
mounts?: ConfigProfileMount[];
|
||||
git_mounts?: GitMount[];
|
||||
files?: Record<string, string>;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useState } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import type { GitMount } from "../api/config_profiles";
|
||||
import type { GitRepository } from "../api/git_repositories";
|
||||
|
||||
interface GitMountEditorProps {
|
||||
mounts: GitMount[];
|
||||
repositories: GitRepository[];
|
||||
onChange: (mounts: GitMount[]) => void;
|
||||
}
|
||||
|
||||
export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEditorProps) => {
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [newMount, setNewMount] = useState<GitMount>({
|
||||
repo_id: "",
|
||||
source_path: ".",
|
||||
target_path: "",
|
||||
branch: "",
|
||||
});
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newMount.repo_id || !newMount.target_path) return;
|
||||
onChange([...mounts, { ...newMount }]);
|
||||
setNewMount({ repo_id: "", source_path: ".", target_path: "", branch: "" });
|
||||
};
|
||||
|
||||
const handleUpdate = (index: number, updated: GitMount) => {
|
||||
const updatedMounts = [...mounts];
|
||||
updatedMounts[index] = updated;
|
||||
onChange(updatedMounts);
|
||||
setEditingIndex(null);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChange(mounts.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const validatePath = (path: string, isTarget: boolean): string | null => {
|
||||
if (!path) return isTarget ? "Target path is required" : null;
|
||||
if (path.includes("..")) return "Path cannot contain ..";
|
||||
if (isTarget && !path.startsWith("/")) return "Target path must be absolute";
|
||||
if (!isTarget && path.startsWith("/")) return "Source path must be relative";
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="git-mount-editor">
|
||||
<h4 className="section-subtitle">Git Mounts</h4>
|
||||
|
||||
{mounts.length > 0 && (
|
||||
<div className="git-mount-list">
|
||||
{mounts.map((mount, index) => (
|
||||
<div key={index} className="git-mount-item">
|
||||
{editingIndex === index ? (
|
||||
<GitMountForm
|
||||
mount={mount}
|
||||
repositories={repositories}
|
||||
onSave={(updated) => handleUpdate(index, updated)}
|
||||
onCancel={() => setEditingIndex(null)}
|
||||
validatePath={validatePath}
|
||||
/>
|
||||
) : (
|
||||
<div className="git-mount-display">
|
||||
<div className="git-mount-info">
|
||||
<span className="git-mount-repo">
|
||||
{repositories.find((r) => r.id === mount.repo_id)?.name || mount.repo_id}
|
||||
</span>
|
||||
<span className="git-mount-paths">
|
||||
{mount.source_path || "."} → {mount.target_path}
|
||||
</span>
|
||||
{mount.branch && (
|
||||
<span className="git-mount-branch">@{mount.branch}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="git-mount-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => setEditingIndex(index)}
|
||||
title="Edit"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button danger"
|
||||
onClick={() => handleRemove(index)}
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="git-mount-add">
|
||||
<h5>Add Git Mount</h5>
|
||||
<GitMountForm
|
||||
mount={newMount}
|
||||
repositories={repositories}
|
||||
onSave={handleAdd}
|
||||
onCancel={() => setNewMount({ repo_id: "", source_path: ".", target_path: "", branch: "" })}
|
||||
validatePath={validatePath}
|
||||
isNew
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface GitMountFormProps {
|
||||
mount: GitMount;
|
||||
repositories: GitRepository[];
|
||||
onSave: (mount: GitMount) => void;
|
||||
onCancel: () => void;
|
||||
validatePath: (path: string, isTarget: boolean) => string | null;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isNew }: GitMountFormProps) => {
|
||||
const [form, setForm] = useState<GitMount>({ ...mount });
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const handleChange = (field: keyof GitMount, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!form.repo_id) {
|
||||
newErrors.repo_id = "Repository is required";
|
||||
}
|
||||
|
||||
const sourceError = validatePath(form.source_path || ".", false);
|
||||
if (sourceError) newErrors.source_path = sourceError;
|
||||
|
||||
const targetError = validatePath(form.target_path, true);
|
||||
if (targetError) newErrors.target_path = targetError;
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(form);
|
||||
if (isNew) {
|
||||
setForm({ repo_id: "", source_path: ".", target_path: "", branch: "" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="git-mount-form">
|
||||
<div className="form-row">
|
||||
<label>Repository</label>
|
||||
<select
|
||||
value={form.repo_id}
|
||||
onChange={(e) => handleChange("repo_id", e.target.value)}
|
||||
className={errors.repo_id ? "error" : ""}
|
||||
>
|
||||
<option value="">Select a repository...</option>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.repo_id && <span className="error-text">{errors.repo_id}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label>Source Path</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.source_path || "."}
|
||||
onChange={(e) => handleChange("source_path", e.target.value)}
|
||||
placeholder="e.g., . or configs/*.json"
|
||||
className={errors.source_path ? "error" : ""}
|
||||
/>
|
||||
<span className="hint">Relative path in repo (supports glob patterns)</span>
|
||||
{errors.source_path && <span className="error-text">{errors.source_path}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label>Target Path</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.target_path}
|
||||
onChange={(e) => handleChange("target_path", e.target.value)}
|
||||
placeholder="e.g., /app/config"
|
||||
className={errors.target_path ? "error" : ""}
|
||||
/>
|
||||
<span className="hint">Absolute path inside container</span>
|
||||
{errors.target_path && <span className="error-text">{errors.target_path}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label>Branch (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.branch || ""}
|
||||
onChange={(e) => handleChange("branch", e.target.value)}
|
||||
placeholder="e.g., main or v1.0"
|
||||
/>
|
||||
<span className="hint">Branch or tag to checkout</span>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="primary-button" onClick={handleSubmit}>
|
||||
{isNew ? "Add" : "Save"}
|
||||
</button>
|
||||
<button type="button" className="secondary-button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
type ResolvedProfile,
|
||||
} from "../api/config_profiles";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import type { Project } from "../types";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { GitMountEditor } from "../components/git-mount-editor";
|
||||
|
||||
type Status = "loading" | "ready" | "error";
|
||||
type MobileView = "list" | "detail" | "edit";
|
||||
@@ -32,6 +34,7 @@ export const ConfigProfilesPage = () => {
|
||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
|
||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
@@ -47,6 +50,7 @@ export const ConfigProfilesPage = () => {
|
||||
env_vars: {},
|
||||
runtime_hints: {},
|
||||
mounts: [],
|
||||
git_mounts: [],
|
||||
files: {},
|
||||
is_default: false,
|
||||
});
|
||||
@@ -67,6 +71,19 @@ export const ConfigProfilesPage = () => {
|
||||
setProfiles(profs || []);
|
||||
setProjects(projs || []);
|
||||
setToolTypes(types || []);
|
||||
|
||||
// Load repositories from all projects
|
||||
const allRepos: GitRepository[] = [];
|
||||
for (const project of projs || []) {
|
||||
try {
|
||||
const repos = await listRepositories(project.id);
|
||||
allRepos.push(...repos);
|
||||
} catch {
|
||||
// Skip projects we can't access
|
||||
}
|
||||
}
|
||||
setRepositories(allRepos);
|
||||
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
@@ -84,6 +101,7 @@ export const ConfigProfilesPage = () => {
|
||||
env_vars: {},
|
||||
runtime_hints: {},
|
||||
mounts: [],
|
||||
git_mounts: [],
|
||||
files: {},
|
||||
is_default: false,
|
||||
});
|
||||
@@ -102,6 +120,7 @@ export const ConfigProfilesPage = () => {
|
||||
env_vars: profile.env_vars,
|
||||
runtime_hints: profile.runtime_hints,
|
||||
mounts: profile.mounts,
|
||||
git_mounts: profile.git_mounts || [],
|
||||
files: profile.files,
|
||||
is_default: profile.is_default,
|
||||
});
|
||||
@@ -1239,6 +1258,14 @@ export const ConfigProfilesPage = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<GitMountEditor
|
||||
mounts={formData.git_mounts || []}
|
||||
repositories={repositories}
|
||||
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dialog-actions" style={{ marginTop: "1rem", position: "sticky", bottom: "1rem", background: "var(--surface)", padding: "1rem", borderRadius: "0.5rem", border: "1px solid var(--border)" }}>
|
||||
<button type="submit" disabled={saveStatus === "saving"}>
|
||||
<Icon name={isCreating ? "add" : "save"} size="sm" />
|
||||
|
||||
@@ -4006,3 +4006,128 @@ a.nav-item,
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Git Mount Editor Styles */
|
||||
.git-mount-editor {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.git-mount-editor .section-subtitle {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.git-mount-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.git-mount-item {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.git-mount-display {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.git-mount-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.git-mount-repo {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.git-mount-paths {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.git-mount-branch {
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.git-mount-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.git-mount-add {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.git-mount-add h5 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.git-mount-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.git-mount-form .form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.git-mount-form .form-row label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.git-mount-form .form-row input,
|
||||
.git-mount-form .form-row select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.git-mount-form .form-row input.error,
|
||||
.git-mount-form .form-row select.error {
|
||||
border-color: #cd3131;
|
||||
}
|
||||
|
||||
.git-mount-form .form-row .hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.git-mount-form .form-row .error-text {
|
||||
font-size: 0.75rem;
|
||||
color: #cd3131;
|
||||
}
|
||||
|
||||
.git-mount-form .form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-26
|
||||
@@ -0,0 +1,75 @@
|
||||
## Context
|
||||
|
||||
Config profiles currently support inline files and inline mounts, but users cannot reference git repositories. This forces users to either copy-paste file contents or use the generic volume mounts system, which doesn't integrate with the git repository model already present in the system.
|
||||
|
||||
Git repositories already have clone, branch, and path management. We need to bridge config profiles with git repositories so users can manage dotfiles and configurations in git and mount them into containers via profiles.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Allow config profiles to reference git repositories for file mounting
|
||||
- Support path mapping (source path in repo → target path in container)
|
||||
- Support branch/tag pinning for reproducible mounts
|
||||
- Integrate seamlessly with existing profile resolution and instance startup
|
||||
- Maintain backward compatibility with existing profiles
|
||||
|
||||
**Non-Goals:**
|
||||
- Manual git clone management by users (system handles cloning automatically)
|
||||
- Writing back to git repos from containers
|
||||
- Git merge conflict resolution inside profiles
|
||||
- Submodules support (out of scope for initial implementation)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Git Mounts as Separate Field (Not Inline in `mounts`)
|
||||
**Decision**: Add `git_mounts` as a top-level field on `ConfigProfile`, separate from existing `mounts`.
|
||||
**Rationale**: Existing `mounts` are inline files staged at instance startup. Git mounts are references to external repositories. Keeping them separate maintains clear semantics and allows independent validation.
|
||||
|
||||
### 2. Bind Mount at Instance Startup (Not Copy)
|
||||
**Decision**: Create bind mounts from the repo filesystem path into the container.
|
||||
**Rationale**: Bind mounts are immediate and don't require copying files. Changes in the repo are reflected in running containers. Alternative (copying files) would require restaging on every instance start and wouldn't reflect live changes.
|
||||
|
||||
### 3. Lazy Repo Validation (Not Strict at Save Time)
|
||||
**Decision**: Validate that the referenced repository exists when the profile is saved, but don't require the repo to be cloned or the branch to exist.
|
||||
**Rationale**: Repositories may be created after profiles. The instance startup process will handle missing repos gracefully (log warning, skip mount).
|
||||
|
||||
### 4. Single Repo per Mount Entry (Not Multiple)
|
||||
**Decision**: Each `git_mounts` entry references exactly one repository.
|
||||
**Rationale**: Simplifies the data model and UI. Users can add multiple entries if they need multiple repos.
|
||||
|
||||
### 5. Glob Pattern Support in Source Path
|
||||
**Decision**: Support glob patterns in `source_path` using standard glob syntax (e.g., `configs/**/*`, `*.sh`).
|
||||
**Rationale**: Users often want to mount categories of files (all config files, all scripts) without listing them individually. The system will expand globs at instance startup and create individual bind mounts for each matched file.
|
||||
|
||||
### 6. Auto-Clone on Instance Startup
|
||||
**Decision**: If a referenced repository is not cloned when an instance starts, the system automatically clones it using the existing clone service.
|
||||
**Rationale**: Users should not need to manually manage repository state. The system already has clone logic (SSH keys, branch checkout) that can be reused. Clone happens lazily at first use.
|
||||
|
||||
### 7. Git Mounts in Profile Preview
|
||||
**Decision**: Include resolved git mounts in the profile preview output with repository names, paths, and branch information.
|
||||
**Rationale**: Users need visibility into what will be mounted before starting an instance. This helps debug configuration issues.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[Risk] Repository clone failure** → **Mitigation**: Clone is attempted at instance startup with full error logging. If clone fails (e.g., bad SSH key, network issue), a clear error is shown and the mount is skipped.
|
||||
|
||||
**[Risk] Glob pattern matches too many files** → **Mitigation**: Limit glob expansion to 100 files per mount. Warn if limit exceeded. Users can use more specific patterns.
|
||||
|
||||
**[Risk] Branch/tag may not exist** → **Mitigation**: Instance startup attempts checkout after clone. Falls back to default branch with warning.
|
||||
|
||||
**[Risk] Performance impact on instance startup** → **Mitigation**: Git mounts are processed in parallel with other startup steps. Clone only happens once per repo. Subsequent instances reuse existing clone.
|
||||
|
||||
**[Trade-off] Bind mounts vs inline files** → Bind mounts don't work across filesystem boundaries (repo must be on same host as Docker). This is acceptable for our single-host deployment model.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Database migration adds `git_mounts` column (nullable JSONB, default empty list)
|
||||
2. Existing profiles have `git_mounts: []` and continue to work
|
||||
3. Frontend UI shows new git mounts section only when editing (not required)
|
||||
4. No changes needed to running instances
|
||||
|
||||
## Decisions Resolved
|
||||
|
||||
1. **Glob patterns**: YES - Support standard glob syntax in `source_path`
|
||||
2. **Profile preview**: YES - Include git mounts in preview/resolve output
|
||||
3. **Auto-clone**: YES - System clones repos automatically, no user reliance
|
||||
@@ -0,0 +1,31 @@
|
||||
## Why
|
||||
|
||||
Config profiles currently only support inline file content, which is impractical for dotfiles and configuration repositories that users manage with git. Users need a way to include files from git repositories (similar to yadm) so they can version-control their dotfiles and mount them into containers at startup.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add `git_mounts` field to config profiles, allowing references to git repositories
|
||||
- Mount specific paths from git repositories into containers at configurable target paths
|
||||
- Support branch/tag selection for reproducible mounts
|
||||
- Integrate with existing profile resolution and instance startup pipeline
|
||||
- Update config profile UI to manage git repository mounts alongside existing mounts
|
||||
- **No breaking changes** - existing profiles continue to work unchanged
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `config-profile-git-mounts`: Mounting files from git repositories into containers via config profiles, including repo selection, path mapping, and branch pinning
|
||||
|
||||
### Modified Capabilities
|
||||
- `tool-instances`: Instance startup pipeline now processes git mounts from resolved profiles before container creation
|
||||
- `git-repo`: Repository model may need branch/tag listing for mount configuration
|
||||
|
||||
## Impact
|
||||
|
||||
- **Backend**: `apps/api/src/models/config_profile.py` - add git_mounts field
|
||||
- **Backend**: `apps/api/src/services/config_profile_resolver.py` - resolve git mounts in profile resolution
|
||||
- **Backend**: `apps/api/src/services/docker.py` or instance startup - bind mount from repo path to container
|
||||
- **Backend**: `apps/api/src/api/config_profiles.py` - CRUD for git mounts
|
||||
- **Frontend**: `apps/web/src/pages/config-profiles.tsx` - UI for managing git mounts
|
||||
- **Frontend**: `apps/web/src/api/config_profiles.ts` - API types for git mounts
|
||||
- **Database**: Migration to add git_mounts column to config_profiles table
|
||||
@@ -0,0 +1,137 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Config profiles can reference git repositories for file mounting
|
||||
The system SHALL allow config profiles to include git repository mounts that bind repository paths into containers.
|
||||
|
||||
#### Scenario: Create profile with git mount
|
||||
- **WHEN** a user creates or updates a config profile with `git_mounts` entries
|
||||
- **THEN** the profile stores each git mount with:
|
||||
- `repo_id`: UUID of the referenced git repository
|
||||
- `source_path`: Path within the repository to mount (e.g., ".", "configs/")
|
||||
- `target_path`: Absolute path inside the container (e.g., "/home/user")
|
||||
- `branch`: Optional branch or tag name (defaults to repository default branch)
|
||||
|
||||
#### Scenario: Git mount validation
|
||||
- **WHEN** a profile with git mounts is saved
|
||||
- **THEN** the system validates that:
|
||||
- The referenced repository exists and belongs to the user's project
|
||||
- `source_path` is a relative path (no leading `/`)
|
||||
- `target_path` is an absolute path (starts with `/`)
|
||||
- `target_path` does not contain path traversal sequences (`..`)
|
||||
|
||||
#### Scenario: Profile with git mounts is resolved
|
||||
- **GIVEN** a config profile with git mounts referencing repository "dotfiles"
|
||||
- **WHEN** the profile is resolved for instance startup
|
||||
- **THEN** the resolved profile includes the git mounts with repository details:
|
||||
- Repository filesystem path
|
||||
- Resolved branch name
|
||||
- Source and target paths
|
||||
|
||||
#### Scenario: Git mount is applied at instance startup
|
||||
- **GIVEN** a resolved profile with git mounts
|
||||
- **WHEN** an instance is started with this profile
|
||||
- **THEN** for each git mount:
|
||||
- The repository filesystem path exists
|
||||
- The source path within the repository exists
|
||||
- A bind mount is created from `repo_path/source_path` to `container:target_path`
|
||||
- **AND** if the repository or path is missing, a warning is logged and the mount is skipped
|
||||
|
||||
### Requirement: Git mounts support glob patterns
|
||||
The system SHALL support glob patterns in `source_path` for matching multiple files.
|
||||
|
||||
#### Scenario: Mount files matching glob pattern
|
||||
- **GIVEN** a git mount with `source_path: "configs/**/*.json"`
|
||||
- **WHEN** the instance is started
|
||||
- **THEN** the system expands the glob pattern within the repository
|
||||
- **AND** creates individual bind mounts for each matched file
|
||||
- **AND** preserves directory structure relative to `target_path`
|
||||
|
||||
#### Scenario: Glob pattern matches nothing
|
||||
- **GIVEN** a git mount with `source_path: "nonexistent/**/*"`
|
||||
- **WHEN** the instance is started
|
||||
- **THEN** the system logs a warning that no files matched the pattern
|
||||
- **AND** the mount is skipped
|
||||
|
||||
#### Scenario: Glob pattern limit exceeded
|
||||
- **GIVEN** a git mount with `source_path: "**/*"` matching 500 files
|
||||
- **WHEN** the instance is started
|
||||
- **THEN** the system limits expansion to 100 files
|
||||
- **AND** logs a warning: "Glob pattern matched 500 files, limited to 100"
|
||||
|
||||
### Requirement: Git mounts trigger automatic cloning
|
||||
The system SHALL automatically clone referenced repositories if they do not exist locally.
|
||||
|
||||
#### Scenario: Repository not cloned at startup
|
||||
- **GIVEN** a git mount referencing a repository that has not been cloned
|
||||
- **WHEN** the instance is started
|
||||
- **THEN** the system triggers a clone operation using the repository's remote URL and SSH key
|
||||
- **AND** the clone proceeds asynchronously
|
||||
- **AND** instance startup continues once clone completes
|
||||
|
||||
#### Scenario: Clone failure handling
|
||||
- **GIVEN** a git mount referencing a repository with an invalid SSH key
|
||||
- **WHEN** the instance attempts to clone
|
||||
- **THEN** the clone operation fails
|
||||
- **AND** an error is logged with details
|
||||
- **AND** the mount is skipped
|
||||
- **AND** instance startup continues with remaining mounts
|
||||
|
||||
### Requirement: Git mounts support branch pinning
|
||||
The system SHALL support pinning git mounts to specific branches or tags.
|
||||
|
||||
#### Scenario: Mount specific branch
|
||||
- **GIVEN** a git mount with `branch: "develop"`
|
||||
- **WHEN** the instance is started
|
||||
- **THEN** the system attempts to checkout the "develop" branch in the repository
|
||||
- **AND** the bind mount uses the files from the checked-out branch
|
||||
|
||||
#### Scenario: Branch fallback to default
|
||||
- **GIVEN** a git mount with `branch: "nonexistent"`
|
||||
- **WHEN** the instance is started
|
||||
- **THEN** the system logs a warning that the branch does not exist
|
||||
- **AND** falls back to the repository's current/default branch
|
||||
- **AND** the bind mount proceeds with the fallback branch
|
||||
|
||||
### Requirement: Git mounts are visible in profile UI
|
||||
The system SHALL display git mounts in the config profile editor.
|
||||
|
||||
#### Scenario: View git mounts in profile editor
|
||||
- **GIVEN** a config profile with git mounts
|
||||
- **WHEN** the user views the profile in the UI
|
||||
- **THEN** the git mounts section displays each mount with:
|
||||
- Repository name
|
||||
- Source path within repository
|
||||
- Target path in container
|
||||
- Branch/tag (if specified)
|
||||
|
||||
#### Scenario: Add git mount via UI
|
||||
- **WHEN** a user adds a git mount in the profile editor
|
||||
- **THEN** they can:
|
||||
- Select from available repositories in the project
|
||||
- Specify the source path (with autocomplete or validation)
|
||||
- Specify the target path in the container
|
||||
- Optionally select a branch/tag
|
||||
|
||||
#### Scenario: Remove git mount via UI
|
||||
- **WHEN** a user removes a git mount from the profile editor
|
||||
- **THEN** the mount is removed from the profile
|
||||
- **AND** existing instances using this profile are unaffected
|
||||
|
||||
### Requirement: Git mounts are visible in profile preview
|
||||
The system SHALL include git mounts in the profile preview/resolve output.
|
||||
|
||||
#### Scenario: Preview shows git mount details
|
||||
- **GIVEN** a config profile with git mounts
|
||||
- **WHEN** the user requests a profile preview
|
||||
- **THEN** the preview includes a "git_mounts" section showing:
|
||||
- Repository name and URL
|
||||
- Source path (with expanded glob matches if applicable)
|
||||
- Target path in container
|
||||
- Resolved branch name
|
||||
- Clone status (exists, will clone, clone failed)
|
||||
|
||||
#### Scenario: Preview warns about missing repository
|
||||
- **GIVEN** a config profile with a git mount referencing a non-existent repository
|
||||
- **WHEN** the user requests a profile preview
|
||||
- **THEN** the preview shows a warning: "Repository [name] not found"
|
||||
- **AND** indicates that the mount will be skipped at startup
|
||||
@@ -0,0 +1,43 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Instance startup processes git mounts from config profiles
|
||||
The system SHALL process git repository mounts from resolved config profiles during instance startup.
|
||||
|
||||
#### Scenario: Instance startup with git mounts
|
||||
- **GIVEN** a tool instance configured with a profile that has git mounts
|
||||
- **WHEN** the instance starts
|
||||
- **THEN** the startup pipeline:
|
||||
1. Resolves the config profile (including inherited profiles)
|
||||
2. Collects all git mounts from the resolved profile
|
||||
3. For each git mount, verifies the repository filesystem path exists
|
||||
4. Creates bind mount entries in the compose file for each valid git mount
|
||||
5. Logs warnings for any invalid or missing git mounts without failing startup
|
||||
|
||||
#### Scenario: Git mount bind mount creation
|
||||
- **GIVEN** a resolved git mount with:
|
||||
- repository filesystem path: `/data/repos/user/project/dotfiles`
|
||||
- source path: `.`
|
||||
- target path: `/home/user`
|
||||
- **WHEN** the instance compose file is generated
|
||||
- **THEN** a volume entry is added:
|
||||
```yaml
|
||||
volumes:
|
||||
- /data/repos/user/project/dotfiles:/home/user:ro
|
||||
```
|
||||
- **AND** the mount is read-only by default
|
||||
|
||||
#### Scenario: Repository auto-clone on startup
|
||||
- **GIVEN** a git mount referencing a repository that has not been cloned
|
||||
- **WHEN** the instance starts
|
||||
- **THEN** the system triggers a clone operation using the repository's remote URL and SSH key
|
||||
- **AND** the system waits for clone completion before proceeding
|
||||
- **AND** the bind mount is created from the cloned repository path
|
||||
|
||||
#### Scenario: Clone failure handling
|
||||
- **GIVEN** a git mount referencing a repository with an invalid SSH key
|
||||
- **WHEN** the instance attempts to clone during startup
|
||||
- **THEN** the clone operation fails
|
||||
- **AND** an error is logged with details
|
||||
- **AND** the mount is skipped
|
||||
- **AND** instance startup continues with remaining mounts
|
||||
- **AND** the instance status is not affected
|
||||
@@ -0,0 +1,64 @@
|
||||
## 1. Database and Models
|
||||
|
||||
- [x] 1.1 Create Alembic migration to add `git_mounts` column to `config_profiles` table (JSONB, nullable, default empty list)
|
||||
- [x] 1.2 Update `ConfigProfile` SQLAlchemy model to include `git_mounts` field
|
||||
- [x] 1.3 Create Pydantic models for GitMount and GitMountCreate schemas with validation
|
||||
- [x] 1.4 Add validation for git mount fields (source_path relative or glob, target_path absolute, no path traversal)
|
||||
|
||||
## 2. Backend API
|
||||
|
||||
- [x] 2.1 Update `POST /config-profiles` endpoint to accept `git_mounts` in request body
|
||||
- [x] 2.2 Update `PUT /config-profiles/{id}` endpoint to accept `git_mounts` updates
|
||||
- [x] 2.3 Update config profile response schemas to include `git_mounts` in output
|
||||
- [x] 2.4 Add validation that referenced repositories exist in the same project
|
||||
- [x] 2.5 Update `GET /config-profiles/{id}/preview` to include resolved git mounts
|
||||
|
||||
## 3. Profile Resolution
|
||||
|
||||
- [x] 3.1 Update `config_profile_resolver.py` to include git mounts in resolved profile output
|
||||
- [x] 3.2 Ensure git mounts from included profiles are merged (with override rules)
|
||||
- [ ] 3.3 Add tests for profile resolution with git mounts
|
||||
|
||||
## 4. Instance Startup Integration
|
||||
|
||||
- [x] 4.1 Modify instance startup pipeline to process git mounts from resolved profile
|
||||
- [x] 4.2 Add helper function to clone repository if not present (reusing existing clone service)
|
||||
- [x] 4.3 Add glob pattern expansion for source_path (using standard glob library)
|
||||
- [x] 4.4 Add helper function to checkout specified branch after clone
|
||||
- [x] 4.5 Generate bind mount entries in compose file for each valid git mount
|
||||
- [x] 4.6 Add error logging for missing repos (non-blocking, mount skipped)
|
||||
- [x] 4.7 Ensure git mounts are processed in parallel with other startup steps
|
||||
|
||||
## 5. Frontend Types and API
|
||||
|
||||
- [x] 5.1 Update TypeScript types in `apps/web/src/api/config_profiles.ts` to include GitMount interface
|
||||
- [x] 5.2 Update API client functions to include git_mounts in create/update payloads
|
||||
- [x] 5.3 Add validation helpers for git mount form fields
|
||||
|
||||
## 6. Frontend UI
|
||||
|
||||
- [x] 6.1 Add "Git Mounts" section to config profile editor (below existing mounts)
|
||||
- [x] 6.2 Create GitMountEditor component with repo selector, source/target path inputs (with glob hint), branch selector
|
||||
- [x] 6.3 Add "Add Git Mount" button that opens the editor
|
||||
- [x] 6.4 Display existing git mounts with edit/delete actions
|
||||
- [x] 6.5 Integrate git mounts into profile save flow (include in form submission)
|
||||
- [x] 6.6 Add validation feedback in UI (repo exists, paths valid, branch exists)
|
||||
|
||||
## 7. Testing and Verification
|
||||
|
||||
- [ ] 7.1 Backend unit tests for git mount validation
|
||||
- [ ] 7.2 Backend integration tests for profile CRUD with git mounts
|
||||
- [ ] 7.3 Test instance startup with git mounts (verify bind mounts created)
|
||||
- [ ] 7.4 Test auto-clone behavior (clone triggered, mount created)
|
||||
- [ ] 7.5 Test clone failure handling (error logged, mount skipped, startup continues)
|
||||
- [ ] 7.6 Test glob pattern expansion (files matched, limit enforced)
|
||||
- [ ] 7.7 Test branch checkout behavior (success and fallback)
|
||||
- [x] 7.8 Frontend type check passes
|
||||
- [x] 7.9 Frontend production build succeeds
|
||||
- [ ] 7.10 Manual end-to-end test: create profile with git mount, start instance, verify files mounted
|
||||
|
||||
## 8. Documentation
|
||||
|
||||
- [ ] 8.1 Update API documentation with new git_mounts fields
|
||||
- [ ] 8.2 Add user guide section for using git repositories in config profiles
|
||||
- [ ] 8.3 Document branch pinning behavior and fallback rules
|
||||
Reference in New Issue
Block a user