feat: add external repository support for config profile git mounts

- Add POST /repositories endpoint for external repos (no project_id)
- Update GitRepositoryResponse to allow nullable project_id
- Update list_repositories to support listing all user repos
- Add _pull_repository_updates for auto-pull on container creation
- Update git mount validation to allow external repos
- Frontend: Update listRepositories to support optional projectId
- Spec updates: external repos, auto-clone, per-instance isolation
This commit is contained in:
Alex Blank
2026-05-27 11:03:12 +02:00
parent 943b9db5c7
commit e07938098a
5 changed files with 187 additions and 11 deletions
+107 -1
View File
@@ -222,7 +222,7 @@ class GitRepositoryResponse(BaseModel):
id: uuid.UUID
name: str
path: str
project_id: uuid.UUID
project_id: uuid.UUID | None
owner_id: uuid.UUID
is_mirror: bool
remote_url: str | None
@@ -345,6 +345,112 @@ async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
return URLParseResponse(**result)
@router.post(
"/repositories",
response_model=GitRepositoryResponse,
status_code=status.HTTP_201_CREATED,
summary="Create an external repository",
description="Create a new external git repository (not tied to any project). Can clone from remote URL.",
)
async def create_external_repository(
data: GitRepositoryCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> GitRepository:
"""Create a new external git repository.
External repositories are not tied to any project and can be used
across all projects for config profile git mounts.
Args:
data: Repository creation data including name and optional remote URL.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The newly created external repository.
"""
_user = await _get_user(session, user_id)
# Check for duplicate name (external repos only)
existing = await session.execute(
select(GitRepository).where(
GitRepository.project_id.is_(None),
GitRepository.owner_id == user_id,
GitRepository.name == data.name,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
# Validate and potentially correct the URL
remote_url = data.remote_url
if remote_url and not data.force_original_url:
parse_result = parse_git_url(remote_url)
if parse_result["needs_parsing"] and parse_result["base_url"]:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"message": "The provided URL appears to be a browser URL, not a git clone URL",
"suggested_url": parse_result["base_url"],
"original_url": remote_url,
"error_code": "URL_NEEDS_PARSING",
},
)
if parse_result["base_url"]:
remote_url = parse_result["base_url"]
# Validate SSH key if provided
ssh_key_id = None
ssh_key = None
if data.ssh_key_id:
try:
ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
if ssh_key.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user")
if remote_url:
_preflight_remote_repository(remote_url, ssh_key)
# Create external repo with no project
repo = GitRepository(
name=data.name,
path="", # Will be set after clone
project_id=None,
owner_id=user_id,
remote_url=remote_url,
ssh_key_id=ssh_key_id,
)
session.add(repo)
await session.flush()
# Set path and optionally clone
repo_path = f"/data/repos/external/{user_id}/{repo.id}"
repo.path = repo_path
if remote_url:
try:
_clone_working_repository(remote_url, repo_path, ssh_key)
repo.is_mirror = False
except Exception as exc:
await session.rollback()
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}")
else:
# Initialize empty repo
os.makedirs(repo_path, exist_ok=True)
subprocess.run(["git", "init", repo_path], check=True, capture_output=True)
repo.is_mirror = False
await session.commit()
return repo
@router.post(
"/{project_id}/repositories",
response_model=GitRepositoryResponse,
+38
View File
@@ -150,6 +150,15 @@ async def _resolve_single_git_mount(
repo_id, repo_path
)
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
# Handle branch checkout if specified
if branch and repo_path:
@@ -225,6 +234,35 @@ def _checkout_branch(repo_path: str, branch: str) -> None:
raise RuntimeError(f"Failed to checkout branch {branch}: {result.stderr}")
def _pull_repository_updates(repo_path: str, remote_url: str) -> None:
"""Pull latest updates from remote repository.
Used when starting a new container with an existing cloned repository
to ensure the latest code is mounted.
"""
import subprocess
# Fetch latest changes
result = subprocess.run(
["git", "-C", repo_path, "fetch", "origin"],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"Failed to fetch updates: {result.stderr}")
# Pull changes for current branch
result = subprocess.run(
["git", "-C", repo_path, "pull", "origin"],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"Failed to pull updates: {result.stderr}")
def _expand_glob_source(source_path: str, repo_path: str) -> list[str]:
"""Expand glob patterns in source path.