feat: simplify git mounts to use direct URLs instead of repo references
- Change git mount schema from repo_id to remote_url - Remove database lookups for git mount resolution - Clone directly from URL at instance startup - Simplify frontend UI to text input for Git URL - Fix route ordering in git_repositories.py to prevent 422 errors - Update all tests to use remote_url field Breaking change: Git mounts now use remote_url instead of repo_id
This commit is contained in:
@@ -58,18 +58,16 @@ def _calculate_profile_size(data: dict) -> int:
|
||||
|
||||
|
||||
class GitMountItem(BaseModel):
|
||||
repo_id: str = Field(description="UUID of the git repository")
|
||||
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
|
||||
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")
|
||||
@field_validator("remote_url")
|
||||
@classmethod
|
||||
def validate_repo_id(cls, v: str) -> str:
|
||||
try:
|
||||
uuid.UUID(v)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid repo_id UUID: {v}")
|
||||
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")
|
||||
@@ -271,53 +269,23 @@ async def _validate_git_mounts(
|
||||
git_mounts: list[dict],
|
||||
project_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Validate that all referenced git repositories exist and are accessible.
|
||||
"""Validate git mount URLs.
|
||||
|
||||
Repositories must:
|
||||
1. Exist
|
||||
2. Belong to the user (external repos with no project are allowed)
|
||||
3. If project_id is specified, repos can be either:
|
||||
- External repos (project_id is null) belonging to the user
|
||||
- Project repos belonging to that project
|
||||
Simply checks that remote_url looks like a valid git URL.
|
||||
Actual clone validation happens at instance startup time.
|
||||
"""
|
||||
for mount in git_mounts:
|
||||
repo_id = mount.get("repo_id")
|
||||
if not repo_id:
|
||||
remote_url = mount.get("remote_url")
|
||||
if not remote_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Git mount missing repo_id",
|
||||
detail="Git mount missing remote_url",
|
||||
)
|
||||
|
||||
try:
|
||||
repo_uuid = uuid.UUID(repo_id)
|
||||
except ValueError:
|
||||
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
|
||||
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}",
|
||||
)
|
||||
|
||||
# External repos (no project) are always allowed for git mounts
|
||||
if repo.project_id is None:
|
||||
continue
|
||||
|
||||
# Project repos are allowed if they belong to the profile's project
|
||||
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}",
|
||||
detail=f"Invalid git URL: {remote_url}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -232,36 +232,6 @@ class GitRepositoryResponse(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/repositories",
|
||||
response_model=list[GitRepositoryResponse],
|
||||
summary="List repositories",
|
||||
description="List all git repositories in a project.",
|
||||
)
|
||||
async def list_repositories(
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[GitRepository]:
|
||||
"""List all repositories in a project.
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project.
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
List of repositories in the project.
|
||||
"""
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
result = await session.execute(
|
||||
select(GitRepository).where(GitRepository.project_id == project_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/repositories",
|
||||
response_model=list[GitRepositoryResponse],
|
||||
@@ -287,45 +257,6 @@ async def list_user_repositories(
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{project_id}/repositories/{repo_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete a repository",
|
||||
description="Delete a git repository from the project and remove it from disk.",
|
||||
)
|
||||
async def delete_repository(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
"""Delete a repository.
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project.
|
||||
repo_id: UUID of the repository to delete.
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Empty response with 204 status code.
|
||||
"""
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
|
||||
# Remove from disk
|
||||
if os.path.exists(repo.path):
|
||||
shutil.rmtree(repo.path)
|
||||
|
||||
await session.delete(repo)
|
||||
await session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/repositories/parse-url",
|
||||
response_model=URLParseResponse,
|
||||
@@ -451,6 +382,75 @@ async def create_external_repository(
|
||||
return repo
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/repositories",
|
||||
response_model=list[GitRepositoryResponse],
|
||||
summary="List repositories",
|
||||
description="List all git repositories in a project.",
|
||||
)
|
||||
async def list_repositories(
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[GitRepository]:
|
||||
"""List all repositories in a project.
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project.
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
List of repositories in the project.
|
||||
"""
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
result = await session.execute(
|
||||
select(GitRepository).where(GitRepository.project_id == project_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{project_id}/repositories/{repo_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete a repository",
|
||||
description="Delete a git repository from the project and remove it from disk.",
|
||||
)
|
||||
async def delete_repository(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
"""Delete a repository.
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project.
|
||||
repo_id: UUID of the repository to delete.
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Empty response with 204 status code.
|
||||
"""
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||
|
||||
# Remove from disk
|
||||
if os.path.exists(repo.path):
|
||||
shutil.rmtree(repo.path)
|
||||
|
||||
await session.delete(repo)
|
||||
await session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/repositories",
|
||||
response_model=GitRepositoryResponse,
|
||||
|
||||
@@ -96,78 +96,58 @@ async def _resolve_single_git_mount(
|
||||
) -> list[dict]:
|
||||
"""Resolve a single git mount to volume mount entries.
|
||||
|
||||
Clones directly from remote_url, no database lookup needed.
|
||||
Returns a list of volume mounts (one for each matched file/directory).
|
||||
"""
|
||||
repo_id = git_mount.get("repo_id")
|
||||
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")
|
||||
|
||||
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)
|
||||
if not remote_url or not target_path:
|
||||
logger.warning("Invalid git mount skipped: missing remote_url or target_path")
|
||||
return []
|
||||
|
||||
# Determine repo path - use existing or auto-clone
|
||||
repo_path = repo.path
|
||||
if not instance_dir:
|
||||
logger.warning("Git mount skipped: no instance_dir provided for cloning")
|
||||
return []
|
||||
|
||||
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
|
||||
# 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_dir = os.path.join(instance_dir, "git-mounts", f"{repo_name}-{url_hash}")
|
||||
|
||||
# Clone or pull the repository
|
||||
repo_path = clone_dir
|
||||
if not os.path.exists(clone_dir):
|
||||
try:
|
||||
repo_path = await asyncio.to_thread(
|
||||
clone_repository,
|
||||
remote_url,
|
||||
None, # No SSH key for now - can be added later
|
||||
os.path.dirname(clone_dir),
|
||||
branch or "main",
|
||||
)
|
||||
logger.info("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
|
||||
if repo.remote_url:
|
||||
try:
|
||||
await asyncio.to_thread(_pull_repository_updates, repo_path, repo.remote_url)
|
||||
logger.info("Pulled updates for repository %s", repo.name)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to pull updates for repository %s: %s", repo.name, exc)
|
||||
# Continue with existing code as fallback
|
||||
try:
|
||||
await asyncio.to_thread(_pull_repository_updates, repo_path, remote_url)
|
||||
logger.info("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:
|
||||
try:
|
||||
await asyncio.to_thread(_checkout_branch, repo_path, branch)
|
||||
logger.info("Checked out branch %s for repository %s", branch, repo.name)
|
||||
logger.info("Checked out branch %s for %s", branch, remote_url)
|
||||
except Exception as exc:
|
||||
logger.warning("Branch checkout failed for %s@%s: %s", repo.name, branch, exc)
|
||||
# Continue with current branch as fallback
|
||||
logger.warning("Branch checkout failed for %s@%s: %s", remote_url, branch, exc)
|
||||
|
||||
# Build source path and expand globs
|
||||
if source_path and source_path != ".":
|
||||
@@ -179,7 +159,7 @@ async def _resolve_single_git_mount(
|
||||
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)
|
||||
logger.warning("Git mount skipped: no files matched source path %s in %s", source_path, remote_url)
|
||||
return []
|
||||
|
||||
volume_mounts = []
|
||||
@@ -201,7 +181,7 @@ async def _resolve_single_git_mount(
|
||||
"target": final_target,
|
||||
"type": "bind",
|
||||
})
|
||||
logger.info("Added git mount: %s -> %s (repo: %s)", matched_path, final_target, repo.name)
|
||||
logger.info("Added git mount: %s -> %s (url: %s)", matched_path, final_target, remote_url)
|
||||
|
||||
return volume_mounts
|
||||
|
||||
|
||||
@@ -176,13 +176,13 @@ def _merge_git_mounts(
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Merge git mounts from included profiles.
|
||||
|
||||
Later mounts override earlier ones with the same repo_id + target_path combo.
|
||||
Later mounts override earlier ones with the same remote_url + 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)}
|
||||
# Build lookup by (remote_url, target_path)
|
||||
seen = {(m["remote_url"], m["target_path"]): i for i, m in enumerate(result)}
|
||||
for mount in overlay:
|
||||
key = (mount["repo_id"], mount["target_path"])
|
||||
key = (mount["remote_url"], mount["target_path"])
|
||||
if key in seen:
|
||||
result[seen[key]] = dict(mount)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user