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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -35,8 +35,15 @@ export async function parseGitUrl(url: string): Promise<URLParseResult> {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function listRepositories(projectId: string): Promise<GitRepository[]> {
|
||||
const response = await apiClient.get(`/projects/${projectId}/repositories`);
|
||||
export async function listRepositories(projectId?: string): Promise<GitRepository[]> {
|
||||
if (projectId) {
|
||||
const response = await apiClient.get<GitRepository[]>(
|
||||
`/projects/${projectId}/repositories`
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
// List all user repositories (including external)
|
||||
const response = await apiClient.get<GitRepository[]>("/repositories");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ The system SHALL allow config profiles to include git repository mounts that bin
|
||||
#### Scenario: Git mount validation
|
||||
- **WHEN** a profile with git mounts is saved
|
||||
- **THEN** the system validates that:
|
||||
- The referenced repository exists and belongs to the user's project
|
||||
- The referenced repository exists and is owned by the user
|
||||
- Repositories can be external (not tied to any project) or project-based
|
||||
- `source_path` is a relative path (no leading `/`)
|
||||
- `target_path` is an absolute path (starts with `/`)
|
||||
- `target_path` does not contain path traversal sequences (`..`)
|
||||
@@ -59,15 +60,22 @@ The system SHALL support glob patterns in `source_path` for matching multiple fi
|
||||
- **AND** logs a warning: "Glob pattern matched 500 files, limited to 100"
|
||||
|
||||
### Requirement: Git mounts trigger automatic cloning
|
||||
The system SHALL automatically clone referenced repositories if they do not exist locally.
|
||||
The system SHALL automatically clone referenced repositories to a persistent storage location on every new container creation. Each instance gets its own fresh clone.
|
||||
|
||||
#### Scenario: Repository not cloned at startup
|
||||
- **GIVEN** a git mount referencing a repository that has not been cloned
|
||||
- **WHEN** the instance is started
|
||||
- **THEN** the system triggers a clone operation using the repository's remote URL and SSH key
|
||||
#### Scenario: Repository cloned on container creation
|
||||
- **GIVEN** a git mount referencing a repository
|
||||
- **WHEN** a new container is created with this profile
|
||||
- **THEN** the system clones the repository to a persistent location: `/data/repos/<user_id>/<repo_name>.git`
|
||||
- **AND** the clone proceeds asynchronously
|
||||
- **AND** instance startup continues once clone completes
|
||||
|
||||
#### Scenario: Existing clone updated on new container creation
|
||||
- **GIVEN** a repository that was previously cloned to the persistent location
|
||||
- **WHEN** a new container is created with this profile
|
||||
- **THEN** the system pulls the latest updates from the remote
|
||||
- **AND** checks out the specified branch (or default branch if not specified)
|
||||
- **AND** uses the updated clone for the bind mount
|
||||
|
||||
#### Scenario: Clone failure handling
|
||||
- **GIVEN** a git mount referencing a repository with an invalid SSH key
|
||||
- **WHEN** the instance attempts to clone
|
||||
@@ -76,6 +84,12 @@ The system SHALL automatically clone referenced repositories if they do not exis
|
||||
- **AND** the mount is skipped
|
||||
- **AND** instance startup continues with remaining mounts
|
||||
|
||||
#### Scenario: Per-instance isolation
|
||||
- **GIVEN** a git mount referencing a repository
|
||||
- **WHEN** multiple instances are created using the same profile
|
||||
- **THEN** each instance gets its own independent clone
|
||||
- **AND** changes made in one container do not affect other containers
|
||||
|
||||
### Requirement: Git mounts support branch pinning
|
||||
The system SHALL support pinning git mounts to specific branches or tags.
|
||||
|
||||
@@ -107,7 +121,7 @@ The system SHALL display git mounts in the config profile editor.
|
||||
#### Scenario: Add git mount via UI
|
||||
- **WHEN** a user adds a git mount in the profile editor
|
||||
- **THEN** they can:
|
||||
- Select from available repositories in the project
|
||||
- Select from all user-owned repositories (external repos not tied to any project are shown)
|
||||
- Specify the source path (with autocomplete or validation)
|
||||
- Specify the target path in the container
|
||||
- Optionally select a branch/tag
|
||||
@@ -128,7 +142,7 @@ The system SHALL include git mounts in the profile preview/resolve output.
|
||||
- Source path (with expanded glob matches if applicable)
|
||||
- Target path in container
|
||||
- Resolved branch name
|
||||
- Clone status (exists, will clone, clone failed)
|
||||
- Clone status (will clone on container creation)
|
||||
|
||||
#### Scenario: Preview warns about missing repository
|
||||
- **GIVEN** a config profile with a git mount referencing a non-existent repository
|
||||
|
||||
@@ -62,3 +62,14 @@
|
||||
- [x] 8.1 Update API documentation with new git_mounts fields
|
||||
- [x] 8.2 Add user guide section for using git repositories in config profiles
|
||||
- [x] 8.3 Document branch pinning behavior and fallback rules
|
||||
|
||||
## 9. External Repository Support
|
||||
|
||||
- [x] 9.1 Remove project requirement from git mount validation
|
||||
- [x] 9.2 Add endpoint to create external repositories (no project_id)
|
||||
- [x] 9.3 Update list_repositories endpoint to return all user repos
|
||||
- [x] 9.4 Add endpoint to list external repositories
|
||||
- [x] 9.5 Update spec: repos can be external (not tied to project)
|
||||
- [x] 9.6 Update spec: auto-clone to persistent location on every container creation
|
||||
- [x] 9.7 Update spec: pull updates when creating new containers
|
||||
- [x] 9.8 Update spec: per-instance isolation (no shared clones)
|
||||
|
||||
Reference in New Issue
Block a user