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:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user