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:
Alex Blank
2026-05-26 22:27:04 +02:00
parent adda76a2ff
commit 4c11163bff
15 changed files with 1118 additions and 3 deletions
@@ -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")
+104
View File
@@ -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()
+207 -2
View File
@@ -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)
+3
View File
@@ -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,
}