feat: config profile multi-repo mounts
- Add mappings array support to git_mount entries - Clone repository once per git_mount entry, mount multiple subdirectories - Normalize legacy source_path+target_path to mappings on read - Update _merge_git_mounts to dedup by (remote_url, branch) and concatenate mappings - Add _normalize_git_mount, _clone_git_repo, _resolve_git_mount_mappings helpers - Update GitMountItem Pydantic model with GitMountMapping and model_validator - Update frontend GitMountEditor component with mappings UI - Auto-convert legacy git mount entries to mappings format on load - Add 15 backend unit tests for normalization, resolution, and glob expansion - Update existing config profile resolver tests for new merge behavior Quality gates: pytest 167 passed, frontend typecheck clean Addresses: config-profile-multi-repo-mounts
This commit is contained in:
@@ -2,9 +2,10 @@
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -56,18 +57,9 @@ def _calculate_profile_size(data: dict) -> int:
|
||||
return total
|
||||
|
||||
|
||||
class GitMountItem(BaseModel):
|
||||
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
|
||||
source_path: str = Field(default=".", description="Path within repository (supports glob patterns)")
|
||||
class GitMountMapping(BaseModel):
|
||||
source_path: str = Field(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("remote_url")
|
||||
@classmethod
|
||||
def validate_remote_url(cls, v: str) -> str:
|
||||
if not v.startswith(("http://", "https://", "git@", "ssh://")):
|
||||
raise ValueError("remote_url must be a valid git URL (https://, git@, or ssh://)")
|
||||
return v
|
||||
|
||||
@field_validator("source_path")
|
||||
@classmethod
|
||||
@@ -86,6 +78,51 @@ class GitMountItem(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class GitMountItem(BaseModel):
|
||||
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
|
||||
source_path: str | None = Field(default=None, description="Path within repository (legacy single mapping)")
|
||||
target_path: str | None = Field(default=None, description="Absolute path inside container (legacy single mapping)")
|
||||
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
||||
mappings: list[GitMountMapping] | None = Field(default=None, description="Multiple source/target mappings from the same repo")
|
||||
|
||||
@field_validator("remote_url")
|
||||
@classmethod
|
||||
def validate_remote_url(cls, v: str) -> str:
|
||||
if not v.startswith(("http://", "https://", "git@", "ssh://")):
|
||||
raise ValueError("remote_url must be a valid git URL (https://, git@, or ssh://)")
|
||||
return v
|
||||
|
||||
@field_validator("source_path")
|
||||
@classmethod
|
||||
def validate_source_path(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
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 | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if ".." in v:
|
||||
raise ValueError("target_path cannot contain path traversal (..)")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_mappings_or_legacy(self):
|
||||
has_legacy = self.source_path is not None and self.target_path is not None
|
||||
has_mappings = self.mappings is not None and len(self.mappings) > 0
|
||||
if not has_legacy and not has_mappings:
|
||||
raise ValueError(
|
||||
"Git mount must have either 'mappings' (non-empty array) or both 'source_path' and 'target_path'"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class MountItem(BaseModel):
|
||||
target: str = Field(description="Absolute mount target path")
|
||||
mode: str = Field(default="rw", description="Mount mode: ro or rw")
|
||||
@@ -263,7 +300,7 @@ async def _check_access(
|
||||
async def _validate_git_mounts(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
git_mounts: list[dict],
|
||||
git_mounts: list[Any],
|
||||
project_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Validate git mount URLs.
|
||||
|
||||
@@ -114,6 +114,148 @@ async def _resolve_git_mounts(
|
||||
return volume_mounts
|
||||
|
||||
|
||||
def _normalize_git_mount(entry: dict) -> dict:
|
||||
"""Normalize a git mount entry to the unified mappings format.
|
||||
|
||||
Converts legacy source_path + target_path into a single-entry mappings array.
|
||||
"""
|
||||
entry = dict(entry)
|
||||
if "mappings" not in entry or not entry.get("mappings"):
|
||||
source = entry.get("source_path", ".")
|
||||
target = entry.get("target_path")
|
||||
if target is not None:
|
||||
entry["mappings"] = [{"source_path": source, "target_path": target}]
|
||||
entry.pop("source_path", None)
|
||||
entry.pop("target_path", None)
|
||||
return entry
|
||||
|
||||
|
||||
def _clone_git_repo(
|
||||
remote_url: str,
|
||||
branch: str | None,
|
||||
clone_parent: str,
|
||||
) -> str:
|
||||
"""Clone or pull a git repository.
|
||||
|
||||
Returns the path to the cloned repo (repo-clone directory).
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
url_hash = hashlib.md5(remote_url.encode()).hexdigest()[:12]
|
||||
repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo"
|
||||
clone_dir = os.path.join(clone_parent, "git-mounts", f"{repo_name}-{url_hash}")
|
||||
repo_path = os.path.join(clone_dir, "repo-clone")
|
||||
|
||||
if not os.path.exists(repo_path):
|
||||
try:
|
||||
os.makedirs(clone_dir, exist_ok=True)
|
||||
repo_path = clone_repository(
|
||||
remote_url,
|
||||
None, # No SSH key for now - can be added later
|
||||
clone_dir,
|
||||
branch or "main",
|
||||
)
|
||||
logger.debug("Cloned git mount repository %s to %s", remote_url, repo_path)
|
||||
except Exception as exc:
|
||||
logger.warning("Clone failed for git mount %s: %s", remote_url, exc)
|
||||
raise
|
||||
else:
|
||||
# Repo exists - pull latest updates
|
||||
try:
|
||||
_pull_repository_updates(repo_path, remote_url)
|
||||
logger.debug("Pulled updates for git mount %s", remote_url)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to pull updates for %s: %s", remote_url, exc)
|
||||
|
||||
# Handle branch checkout if specified
|
||||
if branch and repo_path:
|
||||
success = _checkout_branch(repo_path, branch)
|
||||
if success:
|
||||
logger.debug("Checked out branch %s for %s", branch, remote_url)
|
||||
else:
|
||||
logger.warning(
|
||||
"Branch %s not found in %s, using current branch", branch, remote_url
|
||||
)
|
||||
|
||||
return repo_path
|
||||
|
||||
|
||||
def _resolve_git_mount_mappings(
|
||||
repo_path: str,
|
||||
mappings: list[dict],
|
||||
working_directory: str | None,
|
||||
) -> list[dict]:
|
||||
"""Resolve mappings from an already-cloned repo to volume mount entries.
|
||||
|
||||
Returns a flat list of volume mount dicts.
|
||||
"""
|
||||
volume_mounts = []
|
||||
|
||||
for mapping in mappings:
|
||||
source_path = mapping.get("source_path", ".")
|
||||
target_path = mapping.get("target_path")
|
||||
|
||||
if not target_path:
|
||||
logger.warning("Invalid mapping skipped: missing target_path")
|
||||
continue
|
||||
|
||||
# Resolve relative target paths against working directory
|
||||
final_target = target_path
|
||||
if not target_path.startswith("/"):
|
||||
if not working_directory:
|
||||
logger.warning(
|
||||
"Git mount skipped: target_path '%s' is relative but no working_directory is configured. "
|
||||
"Set working_directory in the tool config or use an absolute path.",
|
||||
target_path,
|
||||
)
|
||||
continue
|
||||
final_target = os.path.join(working_directory, target_path)
|
||||
|
||||
# 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 repo",
|
||||
source_path,
|
||||
)
|
||||
continue
|
||||
|
||||
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
|
||||
mount_target = final_target
|
||||
else:
|
||||
# Multiple matches: append relative path to target
|
||||
rel_path = os.path.relpath(matched_path, repo_path)
|
||||
mount_target = os.path.join(final_target, rel_path)
|
||||
|
||||
volume_mounts.append(
|
||||
{
|
||||
"source": matched_path,
|
||||
"target": mount_target,
|
||||
"type": "bind",
|
||||
}
|
||||
)
|
||||
logger.debug(
|
||||
"Added git mount: %s -> %s",
|
||||
matched_path,
|
||||
mount_target,
|
||||
)
|
||||
|
||||
return volume_mounts
|
||||
|
||||
|
||||
async def _resolve_single_git_mount(
|
||||
session: AsyncSession,
|
||||
git_mount: dict,
|
||||
@@ -125,119 +267,33 @@ async def _resolve_single_git_mount(
|
||||
Clones directly from remote_url, no database lookup needed.
|
||||
Returns a list of volume mounts (one for each matched file/directory).
|
||||
"""
|
||||
git_mount = _normalize_git_mount(git_mount)
|
||||
remote_url = git_mount.get("remote_url")
|
||||
source_path = git_mount.get("source_path", ".")
|
||||
target_path = git_mount.get("target_path")
|
||||
branch = git_mount.get("branch")
|
||||
mappings = git_mount.get("mappings", [])
|
||||
|
||||
if not remote_url or not target_path:
|
||||
logger.warning("Invalid git mount skipped: missing remote_url or target_path")
|
||||
if not remote_url:
|
||||
logger.warning("Invalid git mount skipped: missing remote_url")
|
||||
return []
|
||||
|
||||
# Resolve relative target paths against working directory
|
||||
if target_path and not target_path.startswith("/"):
|
||||
if not working_directory:
|
||||
logger.warning(
|
||||
"Git mount skipped: target_path '%s' is relative but no working_directory is configured. "
|
||||
"Set working_directory in the tool config or use an absolute path.",
|
||||
target_path,
|
||||
)
|
||||
return []
|
||||
target_path = os.path.join(working_directory, target_path)
|
||||
logger.debug("Resolved relative target path to %s", target_path)
|
||||
if not mappings:
|
||||
logger.warning("Invalid git mount skipped: no mappings")
|
||||
return []
|
||||
|
||||
if not instance_dir:
|
||||
logger.warning("Git mount skipped: no instance_dir provided for cloning")
|
||||
return []
|
||||
|
||||
# Generate a unique directory name from the URL
|
||||
import hashlib
|
||||
|
||||
url_hash = hashlib.md5(remote_url.encode()).hexdigest()[:12]
|
||||
repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo"
|
||||
clone_parent = os.path.join(instance_dir, "git-mounts", f"{repo_name}-{url_hash}")
|
||||
# clone_repository always creates 'repo-clone' inside the given directory
|
||||
repo_path = os.path.join(clone_parent, "repo-clone")
|
||||
|
||||
# Clone or pull the repository
|
||||
if not os.path.exists(repo_path):
|
||||
try:
|
||||
os.makedirs(clone_parent, exist_ok=True)
|
||||
repo_path = await asyncio.to_thread(
|
||||
clone_repository,
|
||||
remote_url,
|
||||
None, # No SSH key for now - can be added later
|
||||
clone_parent,
|
||||
branch or "main",
|
||||
)
|
||||
logger.debug("Cloned git mount repository %s to %s", remote_url, repo_path)
|
||||
except Exception as exc:
|
||||
logger.warning("Clone failed for git mount %s: %s", remote_url, exc)
|
||||
return []
|
||||
else:
|
||||
# Repo exists - pull latest updates
|
||||
try:
|
||||
await asyncio.to_thread(_pull_repository_updates, repo_path, remote_url)
|
||||
logger.debug("Pulled updates for git mount %s", remote_url)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to pull updates for %s: %s", remote_url, exc)
|
||||
|
||||
# Handle branch checkout if specified
|
||||
if branch and repo_path:
|
||||
success = await asyncio.to_thread(_checkout_branch, repo_path, branch)
|
||||
if success:
|
||||
logger.debug("Checked out branch %s for %s", branch, remote_url)
|
||||
else:
|
||||
logger.warning(
|
||||
"Branch %s not found in %s, using current branch", branch, remote_url
|
||||
)
|
||||
|
||||
# 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 %s",
|
||||
source_path,
|
||||
remote_url,
|
||||
try:
|
||||
repo_path = await asyncio.to_thread(
|
||||
_clone_git_repo, remote_url, branch, instance_dir
|
||||
)
|
||||
except Exception:
|
||||
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.debug(
|
||||
"Added git mount: %s -> %s (url: %s)",
|
||||
matched_path,
|
||||
final_target,
|
||||
remote_url,
|
||||
)
|
||||
|
||||
return volume_mounts
|
||||
# Resolve all mappings from the cloned repo
|
||||
return _resolve_git_mount_mappings(repo_path, mappings, working_directory)
|
||||
|
||||
|
||||
def _checkout_branch(repo_path: str, branch: str) -> bool:
|
||||
|
||||
@@ -176,21 +176,59 @@ def _merge_git_mounts(
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Merge git mounts from included profiles.
|
||||
|
||||
Later mounts override earlier ones with the same remote_url + target_path combo.
|
||||
Entries with the same remote_url + branch have their mappings concatenated.
|
||||
Different repos are kept as separate entries.
|
||||
All entries are normalized to the mappings format.
|
||||
"""
|
||||
result = list(base)
|
||||
# Build lookup by (remote_url, target_path)
|
||||
seen = {(m["remote_url"], m["target_path"]): i for i, m in enumerate(result)}
|
||||
# Normalize existing entries to mappings format
|
||||
for i, m in enumerate(result):
|
||||
result[i] = _normalize_git_mount_entry(dict(m))
|
||||
|
||||
# Build lookup by (remote_url, branch)
|
||||
seen = {}
|
||||
for i, m in enumerate(result):
|
||||
key = (m["remote_url"], m.get("branch"))
|
||||
seen[key] = i
|
||||
|
||||
for mount in overlay:
|
||||
key = (mount["remote_url"], mount["target_path"])
|
||||
mount = _normalize_git_mount_entry(dict(mount))
|
||||
key = (mount["remote_url"], mount.get("branch"))
|
||||
if key in seen:
|
||||
result[seen[key]] = dict(mount)
|
||||
# Same repo+branch: concatenate mappings, dedup by (source_path, target_path)
|
||||
existing = result[seen[key]]
|
||||
existing_sources = {
|
||||
(m["source_path"], m["target_path"])
|
||||
for m in existing.get("mappings", [])
|
||||
}
|
||||
for mapping in mount.get("mappings", []):
|
||||
map_key = (mapping["source_path"], mapping["target_path"])
|
||||
if map_key not in existing_sources:
|
||||
existing["mappings"].append(dict(mapping))
|
||||
existing_sources.add(map_key)
|
||||
else:
|
||||
seen[key] = len(result)
|
||||
result.append(dict(mount))
|
||||
result.append(mount)
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_git_mount_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize a git mount entry to the unified mappings format.
|
||||
|
||||
Converts legacy source_path + target_path into a single-entry mappings array.
|
||||
"""
|
||||
entry = dict(entry)
|
||||
if "mappings" not in entry or not entry.get("mappings"):
|
||||
source = entry.get("source_path", ".")
|
||||
target = entry.get("target_path")
|
||||
if target is not None:
|
||||
entry["mappings"] = [{"source_path": source, "target_path": target}]
|
||||
# Remove legacy fields once normalized
|
||||
entry.pop("source_path", None)
|
||||
entry.pop("target_path", None)
|
||||
return entry
|
||||
|
||||
|
||||
async def _resolve_profile_recursive(
|
||||
session: AsyncSession,
|
||||
profile_id: uuid.UUID,
|
||||
|
||||
Reference in New Issue
Block a user