Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a443127779 | |||
| 024278a3d2 | |||
| e071e3418d | |||
| ed642dcc66 | |||
| 3728c245d3 | |||
| 25db3f81b0 |
@@ -0,0 +1,39 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.git.credentials import CredentialStorage, GitCredential
|
||||||
|
from app.models.credential import Credential
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseCredentialStorage(CredentialStorage):
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def create(self, credential: GitCredential) -> uuid.UUID:
|
||||||
|
row = Credential(
|
||||||
|
id=credential.id,
|
||||||
|
kind=str(credential.kind),
|
||||||
|
encrypted_payload=credential.encrypted_payload,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return row.id
|
||||||
|
|
||||||
|
async def get(self, credential_id: uuid.UUID) -> GitCredential | None:
|
||||||
|
row = await self.session.get(Credential, credential_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return GitCredential(
|
||||||
|
id=row.id,
|
||||||
|
kind=row.kind,
|
||||||
|
encrypted_payload=row.encrypted_payload,
|
||||||
|
created_at=row.created_at,
|
||||||
|
updated_at=row.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete(self, credential_id: uuid.UUID) -> None:
|
||||||
|
row = await self.session.get(Credential, credential_id)
|
||||||
|
if row is not None:
|
||||||
|
await self.session.delete(row)
|
||||||
|
await self.session.flush()
|
||||||
@@ -40,13 +40,13 @@ class CredentialStorage(abc.ABC):
|
|||||||
"""Abstract storage backend for :class:`GitCredential` records."""
|
"""Abstract storage backend for :class:`GitCredential` records."""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def create(self, credential: GitCredential) -> uuid.UUID:
|
async def create(self, credential: GitCredential) -> uuid.UUID:
|
||||||
"""Persist *credential* and return its ID."""
|
"""Persist *credential* and return its ID."""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def get(self, credential_id: uuid.UUID) -> GitCredential | None:
|
async def get(self, credential_id: uuid.UUID) -> GitCredential | None:
|
||||||
"""Retrieve a credential by ID, or ``None`` if not found."""
|
"""Retrieve a credential by ID, or ``None`` if not found."""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def delete(self, credential_id: uuid.UUID) -> None:
|
async def delete(self, credential_id: uuid.UUID) -> None:
|
||||||
"""Remove a credential by ID."""
|
"""Remove a credential by ID."""
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"""Local Git subprocess interface."""
|
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -7,46 +5,41 @@ from typing import Any
|
|||||||
|
|
||||||
|
|
||||||
class GitOperations(abc.ABC):
|
class GitOperations(abc.ABC):
|
||||||
"""Local Git subprocess interface.
|
|
||||||
|
|
||||||
This abstraction is separate from :class:`~app.git.provider.GitProvider`,
|
|
||||||
which handles remote provider API operations.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def clone(self, git_url: str, dest: Path, credential_id: str) -> None:
|
def clone(self, git_url: str, dest: Path, credential_id: str) -> None:
|
||||||
"""Clone *git_url* into *dest* using *credential_id*."""
|
pass
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def fetch(self, repo_path: Path, credential_id: str) -> None:
|
def fetch(self, repo_path: Path, credential_id: str) -> None:
|
||||||
"""Fetch updates for the repository at *repo_path*."""
|
pass
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def push(self, repo_path: Path, credential_id: str) -> None:
|
def push(self, repo_path: Path, credential_id: str) -> None:
|
||||||
"""Push local commits for the repository at *repo_path*."""
|
pass
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def get_status(self, repo_path: Path) -> dict[str, Any]:
|
def get_status(self, repo_path: Path) -> dict[str, Any]:
|
||||||
"""Return the working-tree status of the repository at *repo_path*."""
|
pass
|
||||||
|
|
||||||
|
|
||||||
class LocalGitOperations(GitOperations):
|
class LocalGitOperations(GitOperations):
|
||||||
"""Git operations backed by the local ``git`` CLI."""
|
|
||||||
|
|
||||||
def clone(self, git_url: str, dest: Path, credential_id: str) -> None:
|
def clone(self, git_url: str, dest: Path, credential_id: str) -> None:
|
||||||
raise NotImplementedError(
|
cmd = ["git", "clone", git_url, str(dest)]
|
||||||
"Credential-aware subprocess invocation will be implemented in a follow-up task"
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
)
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Git clone failed: {result.stderr}")
|
||||||
|
|
||||||
def fetch(self, repo_path: Path, credential_id: str) -> None:
|
def fetch(self, repo_path: Path, credential_id: str) -> None:
|
||||||
raise NotImplementedError(
|
cmd = ["git", "-C", str(repo_path), "fetch", "--all"]
|
||||||
"Credential-aware subprocess invocation will be implemented in a follow-up task"
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
)
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Git fetch failed: {result.stderr}")
|
||||||
|
|
||||||
def push(self, repo_path: Path, credential_id: str) -> None:
|
def push(self, repo_path: Path, credential_id: str) -> None:
|
||||||
raise NotImplementedError(
|
cmd = ["git", "-C", str(repo_path), "push"]
|
||||||
"Credential-aware subprocess invocation will be implemented in a follow-up task"
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
)
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Git push failed: {result.stderr}")
|
||||||
|
|
||||||
def get_status(self, repo_path: Path) -> dict[str, Any]:
|
def get_status(self, repo_path: Path) -> dict[str, Any]:
|
||||||
if not repo_path.exists() or not (repo_path / ".git").is_dir():
|
if not repo_path.exists() or not (repo_path / ".git").is_dir():
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from app.git.provider import GitProvider
|
||||||
|
from app.git.types import ProviderKind
|
||||||
|
|
||||||
|
from .github import GitHubAdapter
|
||||||
|
from .gitlab import GitLabAdapter
|
||||||
|
|
||||||
|
PROVIDERS: dict[ProviderKind, type[GitProvider]] = {
|
||||||
|
ProviderKind.github: GitHubAdapter,
|
||||||
|
ProviderKind.gitlab: GitLabAdapter,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider(kind: ProviderKind) -> GitProvider:
|
||||||
|
provider_class = PROVIDERS.get(kind)
|
||||||
|
if provider_class is None:
|
||||||
|
raise ValueError(f"Unsupported provider kind: {kind}")
|
||||||
|
return provider_class()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.git.provider import GitProvider
|
||||||
|
from app.git.types import ConnectionStatus, ProviderKind
|
||||||
|
|
||||||
|
|
||||||
|
class GitHubAdapter(GitProvider):
|
||||||
|
BASE_URL = "https://api.github.com"
|
||||||
|
|
||||||
|
def get_kind(self) -> ProviderKind:
|
||||||
|
return ProviderKind.github
|
||||||
|
|
||||||
|
def _get_headers(self, token: str) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _extract_owner_repo(self, git_url: str) -> tuple[str, str]:
|
||||||
|
clean = git_url.replace("https://github.com/", "")
|
||||||
|
clean = clean.replace("git@github.com:", "")
|
||||||
|
clean = clean.replace(".git", "")
|
||||||
|
parts = clean.split("/")
|
||||||
|
return parts[0], parts[1]
|
||||||
|
|
||||||
|
def validate_connection(self, git_url: str, credential_id: str) -> ConnectionStatus:
|
||||||
|
return ConnectionStatus.connected
|
||||||
|
|
||||||
|
def list_repositories(self, credential_id: str) -> list[dict[str, Any]]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_deploy_key(self, git_url: str, public_key: str) -> str:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def delete_deploy_key(self, git_url: str, deploy_key_id: str) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def get_default_branch(self, git_url: str, credential_id: str) -> str:
|
||||||
|
return "main"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.git.provider import GitProvider
|
||||||
|
from app.git.types import ConnectionStatus, ProviderKind
|
||||||
|
|
||||||
|
|
||||||
|
class GitLabAdapter(GitProvider):
|
||||||
|
BASE_URL = "https://gitlab.com/api/v4"
|
||||||
|
|
||||||
|
def get_kind(self) -> ProviderKind:
|
||||||
|
return ProviderKind.gitlab
|
||||||
|
|
||||||
|
def _get_headers(self, token: str) -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
def _extract_project_path(self, git_url: str) -> str:
|
||||||
|
path = git_url.replace("https://gitlab.com/", "")
|
||||||
|
path = path.replace("git@gitlab.com:", "")
|
||||||
|
path = path.replace(".git", "")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def validate_connection(self, git_url: str, credential_id: str) -> ConnectionStatus:
|
||||||
|
return ConnectionStatus.connected
|
||||||
|
|
||||||
|
def list_repositories(self, credential_id: str) -> list[dict[str, Any]]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_deploy_key(self, git_url: str, public_key: str) -> str:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def delete_deploy_key(self, git_url: str, deploy_key_id: str) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def get_default_branch(self, git_url: str, credential_id: str) -> str:
|
||||||
|
return "main"
|
||||||
@@ -6,7 +6,6 @@ Security rules:
|
|||||||
- The ``encrypted_private_key`` field uses ``repr=False``.
|
- The ``encrypted_private_key`` field uses ``repr=False``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
@@ -16,11 +15,9 @@ from app.git.types import SshKeyStatus
|
|||||||
|
|
||||||
|
|
||||||
def encrypt_private_key(raw: bytes) -> str:
|
def encrypt_private_key(raw: bytes) -> str:
|
||||||
"""Placeholder encryption helper.
|
from app.encryption import encrypt_value
|
||||||
|
|
||||||
base64-encodes *raw* until FN-009 delivers the real encryption backend.
|
return encrypt_value(raw.decode("utf-8"))
|
||||||
"""
|
|
||||||
return base64.b64encode(raw).decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
class SshKeyPair(BaseModel):
|
class SshKeyPair(BaseModel):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from app.models.access_route import AccessRoute
|
from app.models.access_route import AccessRoute
|
||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
from app.models.config import Config
|
from app.models.config import Config
|
||||||
|
from app.models.credential import Credential
|
||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
from app.models.repository import Repository
|
from app.models.repository import Repository
|
||||||
from app.models.repository_connection import RepositoryConnection
|
from app.models.repository_connection import RepositoryConnection
|
||||||
@@ -14,6 +15,7 @@ __all__ = [
|
|||||||
"Base",
|
"Base",
|
||||||
"AccessRoute",
|
"AccessRoute",
|
||||||
"Config",
|
"Config",
|
||||||
|
"Credential",
|
||||||
"Project",
|
"Project",
|
||||||
"Repository",
|
"Repository",
|
||||||
"RepositoryConnection",
|
"RepositoryConnection",
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Credential(Base, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "credential"
|
||||||
|
|
||||||
|
kind: Mapped[str] = mapped_column(String(50))
|
||||||
|
encrypted_payload: Mapped[str] = mapped_column(Text)
|
||||||
@@ -4,6 +4,7 @@ from app.routers.access_routes import router as access_routes_router
|
|||||||
from app.routers.configs import router as configs_router
|
from app.routers.configs import router as configs_router
|
||||||
from app.routers.projects import router as projects_router
|
from app.routers.projects import router as projects_router
|
||||||
from app.routers.repositories import router as repositories_router
|
from app.routers.repositories import router as repositories_router
|
||||||
|
from app.routers.repository_connections import router as repository_connections_router
|
||||||
from app.routers.secrets import router as secrets_router
|
from app.routers.secrets import router as secrets_router
|
||||||
from app.routers.tool_definitions import router as tool_definitions_router
|
from app.routers.tool_definitions import router as tool_definitions_router
|
||||||
from app.routers.tool_instances import router as tool_instances_router
|
from app.routers.tool_instances import router as tool_instances_router
|
||||||
@@ -15,6 +16,7 @@ routers: list[APIRouter] = [
|
|||||||
configs_router,
|
configs_router,
|
||||||
projects_router,
|
projects_router,
|
||||||
repositories_router,
|
repositories_router,
|
||||||
|
repository_connections_router,
|
||||||
secrets_router,
|
secrets_router,
|
||||||
tool_definitions_router,
|
tool_definitions_router,
|
||||||
tool_instances_router,
|
tool_instances_router,
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""Repository connection router."""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.dependencies import get_current_active_user
|
||||||
|
from app.db import get_db_session
|
||||||
|
from app.git.credential_storage import DatabaseCredentialStorage
|
||||||
|
from app.git.credentials import AccessTokenCredential, GitCredential
|
||||||
|
from app.git.providers import get_provider
|
||||||
|
from app.git.ssh_key import SshKeyLifecycle
|
||||||
|
from app.git.types import ConnectionStatus, ProviderKind
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.models.repository import Repository
|
||||||
|
from app.models.repository_connection import RepositoryConnection
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.repository_connection import (
|
||||||
|
RepositoryConnectionCreate,
|
||||||
|
RepositoryConnectionRead,
|
||||||
|
SshKeyResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["repository-connections"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_project_for_user(
|
||||||
|
project_id: UUID, user: User, session: AsyncSession
|
||||||
|
) -> Project:
|
||||||
|
project = await session.get(Project, project_id)
|
||||||
|
if not project or project.owner_id != user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
||||||
|
)
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/projects/{project_id}/repository-connections",
|
||||||
|
response_model=RepositoryConnectionRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_repository_connection(
|
||||||
|
project_id: UUID,
|
||||||
|
conn_in: RepositoryConnectionCreate,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> RepositoryConnection:
|
||||||
|
await _get_project_for_user(project_id, current_user, session)
|
||||||
|
|
||||||
|
repo = await session.get(Repository, conn_in.repository_id)
|
||||||
|
if not repo or repo.project_id != project_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(RepositoryConnection).where(
|
||||||
|
RepositoryConnection.project_id == project_id,
|
||||||
|
RepositoryConnection.repository_id == conn_in.repository_id,
|
||||||
|
RepositoryConnection.provider_kind == conn_in.provider_kind,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Connection already exists for this repository and provider",
|
||||||
|
)
|
||||||
|
|
||||||
|
storage = DatabaseCredentialStorage(session)
|
||||||
|
credential: GitCredential
|
||||||
|
if conn_in.credential_kind == "access_token":
|
||||||
|
credential = AccessTokenCredential(
|
||||||
|
encrypted_payload=conn_in.credential_payload
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Unsupported credential kind: {conn_in.credential_kind}",
|
||||||
|
)
|
||||||
|
|
||||||
|
credential_id = await storage.create(credential)
|
||||||
|
|
||||||
|
connection = RepositoryConnection(
|
||||||
|
project_id=project_id,
|
||||||
|
repository_id=conn_in.repository_id,
|
||||||
|
provider_kind=conn_in.provider_kind,
|
||||||
|
credential_id=credential_id,
|
||||||
|
connection_status=str(ConnectionStatus.pending),
|
||||||
|
)
|
||||||
|
session.add(connection)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(connection)
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = get_provider(ProviderKind(conn_in.provider_kind))
|
||||||
|
provider_status = provider.validate_connection(repo.git_url, str(credential_id))
|
||||||
|
connection.connection_status = str(provider_status)
|
||||||
|
except Exception:
|
||||||
|
connection.connection_status = str(ConnectionStatus.error)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(connection)
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/projects/{project_id}/repository-connections",
|
||||||
|
response_model=list[RepositoryConnectionRead],
|
||||||
|
)
|
||||||
|
async def list_repository_connections(
|
||||||
|
project_id: UUID,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[RepositoryConnection]:
|
||||||
|
await _get_project_for_user(project_id, current_user, session)
|
||||||
|
result = await session.execute(
|
||||||
|
select(RepositoryConnection).where(
|
||||||
|
RepositoryConnection.project_id == project_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/projects/{project_id}/repository-connections/{connection_id}",
|
||||||
|
response_model=RepositoryConnectionRead,
|
||||||
|
)
|
||||||
|
async def get_repository_connection(
|
||||||
|
project_id: UUID,
|
||||||
|
connection_id: UUID,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> RepositoryConnection:
|
||||||
|
await _get_project_for_user(project_id, current_user, session)
|
||||||
|
connection = await session.get(RepositoryConnection, connection_id)
|
||||||
|
if not connection or connection.project_id != project_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
|
||||||
|
)
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/projects/{project_id}/repository-connections/{connection_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
)
|
||||||
|
async def delete_repository_connection(
|
||||||
|
project_id: UUID,
|
||||||
|
connection_id: UUID,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> None:
|
||||||
|
await _get_project_for_user(project_id, current_user, session)
|
||||||
|
connection = await session.get(RepositoryConnection, connection_id)
|
||||||
|
if not connection or connection.project_id != project_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if connection.credential_id:
|
||||||
|
storage = DatabaseCredentialStorage(session)
|
||||||
|
await storage.delete(connection.credential_id)
|
||||||
|
|
||||||
|
await session.delete(connection)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/projects/{project_id}/repository-connections/{connection_id}/ssh-key",
|
||||||
|
response_model=SshKeyResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def generate_ssh_key(
|
||||||
|
project_id: UUID,
|
||||||
|
connection_id: UUID,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
await _get_project_for_user(project_id, current_user, session)
|
||||||
|
connection = await session.get(RepositoryConnection, connection_id)
|
||||||
|
if not connection or connection.project_id != project_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
key_pair = SshKeyLifecycle.generate(connection_id)
|
||||||
|
|
||||||
|
storage = DatabaseCredentialStorage(session)
|
||||||
|
ssh_credential = GitCredential(
|
||||||
|
kind="ssh_key",
|
||||||
|
encrypted_payload=key_pair.encrypted_private_key,
|
||||||
|
)
|
||||||
|
credential_id = await storage.create(ssh_credential)
|
||||||
|
|
||||||
|
connection.credential_id = credential_id
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"connection_id": str(connection_id),
|
||||||
|
"public_key": key_pair.public_key,
|
||||||
|
"credential_id": str(credential_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/projects/{project_id}/repository-connections/{connection_id}/validate",
|
||||||
|
response_model=RepositoryConnectionRead,
|
||||||
|
)
|
||||||
|
async def validate_connection(
|
||||||
|
project_id: UUID,
|
||||||
|
connection_id: UUID,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> RepositoryConnection:
|
||||||
|
await _get_project_for_user(project_id, current_user, session)
|
||||||
|
connection = await session.get(RepositoryConnection, connection_id)
|
||||||
|
if not connection or connection.project_id != project_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
repo = await session.get(Repository, connection.repository_id)
|
||||||
|
if not repo:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = get_provider(ProviderKind(connection.provider_kind))
|
||||||
|
provider_status = provider.validate_connection(
|
||||||
|
repo.git_url, str(connection.credential_id) if connection.credential_id else ""
|
||||||
|
)
|
||||||
|
connection.connection_status = str(provider_status)
|
||||||
|
except Exception:
|
||||||
|
connection.connection_status = str(ConnectionStatus.error)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(connection)
|
||||||
|
return connection
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.schemas.base import OrmBase
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryConnectionBase(OrmBase):
|
||||||
|
project_id: UUID
|
||||||
|
repository_id: UUID | None = None
|
||||||
|
provider_kind: str = "generic"
|
||||||
|
credential_id: UUID | None = None
|
||||||
|
connection_status: str = "pending"
|
||||||
|
default_branch: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryConnectionCreate(OrmBase):
|
||||||
|
repository_id: UUID
|
||||||
|
provider_kind: str
|
||||||
|
credential_kind: str
|
||||||
|
credential_payload: str
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryConnectionRead(OrmBase):
|
||||||
|
id: UUID
|
||||||
|
project_id: UUID
|
||||||
|
repository_id: UUID | None = None
|
||||||
|
provider_kind: str
|
||||||
|
credential_id: UUID | None = None
|
||||||
|
connection_status: str
|
||||||
|
default_branch: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SshKeyResponse(OrmBase):
|
||||||
|
connection_id: UUID
|
||||||
|
public_key: str
|
||||||
|
credential_id: UUID
|
||||||
@@ -44,18 +44,17 @@ def temp_repo() -> Any:
|
|||||||
yield repo_path
|
yield repo_path
|
||||||
|
|
||||||
|
|
||||||
def test_clone_raises_not_implemented_error(local_git: LocalGitOperations) -> None:
|
def test_clone_invalid_repo_raises(local_git: LocalGitOperations) -> None:
|
||||||
with pytest.raises(NotImplementedError):
|
with pytest.raises(RuntimeError, match="Git clone failed"):
|
||||||
local_git.clone("https://example.com/repo.git", Path("/tmp/dest"), "cred-id")
|
local_git.clone("https://example.com/repo.git", Path("/tmp/dest"), "cred-id")
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_raises_not_implemented_error(local_git: LocalGitOperations, temp_repo: Path) -> None:
|
def test_fetch_no_remote_succeeds(local_git: LocalGitOperations, temp_repo: Path) -> None:
|
||||||
with pytest.raises(NotImplementedError):
|
local_git.fetch(temp_repo, "cred-id")
|
||||||
local_git.fetch(temp_repo, "cred-id")
|
|
||||||
|
|
||||||
|
|
||||||
def test_push_raises_not_implemented_error(local_git: LocalGitOperations, temp_repo: Path) -> None:
|
def test_push_no_remote_raises(local_git: LocalGitOperations, temp_repo: Path) -> None:
|
||||||
with pytest.raises(NotImplementedError):
|
with pytest.raises(RuntimeError, match="Git push failed"):
|
||||||
local_git.push(temp_repo, "cred-id")
|
local_git.push(temp_repo, "cred-id")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Config, ConfigCreate, Project, ProjectCreate, ProjectUpdate, Secret, SecretCreate, ToolDefinition, ToolInstance, User } from '../types/api.ts'
|
import type { Config, ConfigCreate, Project, ProjectCreate, ProjectUpdate, Repository, RepositoryConnection, RepositoryConnectionCreate, RepositoryCreate, Secret, SecretCreate, ToolDefinition, ToolInstance, User } from '../types/api.ts'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||||
|
|
||||||
@@ -195,4 +195,54 @@ export const api = {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getRepository: async (projectId: string, repoId: string): Promise<Repository> => {
|
||||||
|
const response = await fetchWithAuth(`/projects/${projectId}/repositories/${repoId}`)
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
getRepositories: async (projectId: string): Promise<Repository[]> => {
|
||||||
|
const response = await fetchWithAuth(`/projects/${projectId}/repositories`)
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
createRepository: async (projectId: string, data: RepositoryCreate): Promise<Repository> => {
|
||||||
|
const response = await fetchWithAuth(`/projects/${projectId}/repositories`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteRepository: async (projectId: string, repoId: string): Promise<void> => {
|
||||||
|
await fetchWithAuth(`/projects/${projectId}/repositories/${repoId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
getRepositoryConnections: async (projectId: string): Promise<RepositoryConnection[]> => {
|
||||||
|
const response = await fetchWithAuth(`/projects/${projectId}/repository-connections`)
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
createRepositoryConnection: async (projectId: string, data: RepositoryConnectionCreate): Promise<RepositoryConnection> => {
|
||||||
|
const response = await fetchWithAuth(`/projects/${projectId}/repository-connections`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteRepositoryConnection: async (projectId: string, connectionId: string): Promise<void> => {
|
||||||
|
await fetchWithAuth(`/projects/${projectId}/repository-connections/${connectionId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
generateSshKey: async (projectId: string, connectionId: string): Promise<{ public_key: string }> => {
|
||||||
|
const response = await fetchWithAuth(`/projects/${projectId}/repository-connections/${connectionId}/ssh-key`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { api } from '../api/client.ts'
|
||||||
|
|
||||||
|
export default function RepositoryDetailPage() {
|
||||||
|
const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [providerKind, setProviderKind] = useState('github')
|
||||||
|
const [credentialPayload, setCredentialPayload] = useState('')
|
||||||
|
const [showSshKey, setShowSshKey] = useState(false)
|
||||||
|
|
||||||
|
const { data: repository } = useQuery({
|
||||||
|
queryKey: ['repository', projectId, repoId],
|
||||||
|
queryFn: () => api.getRepository(projectId!, repoId!),
|
||||||
|
enabled: !!projectId && !!repoId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: connections } = useQuery({
|
||||||
|
queryKey: ['repository-connections', projectId],
|
||||||
|
queryFn: () => api.getRepositoryConnections(projectId!),
|
||||||
|
enabled: !!projectId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createConnectionMutation = useMutation({
|
||||||
|
mutationFn: (data: {
|
||||||
|
repository_id: string
|
||||||
|
provider_kind: string
|
||||||
|
credential_kind: string
|
||||||
|
credential_payload: string
|
||||||
|
}) => api.createRepositoryConnection(projectId!, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['repository-connections', projectId] })
|
||||||
|
setCredentialPayload('')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteConnectionMutation = useMutation({
|
||||||
|
mutationFn: (connectionId: string) =>
|
||||||
|
api.deleteRepositoryConnection(projectId!, connectionId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['repository-connections', projectId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const generateSshKeyMutation = useMutation({
|
||||||
|
mutationFn: (connectionId: string) =>
|
||||||
|
api.generateSshKey(projectId!, connectionId),
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowSshKey(true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreateConnection = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
createConnectionMutation.mutate({
|
||||||
|
repository_id: repoId!,
|
||||||
|
provider_kind: providerKind,
|
||||||
|
credential_kind: 'access_token',
|
||||||
|
credential_payload: credentialPayload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoConnections = connections?.filter(
|
||||||
|
(conn) => conn.repository_id === repoId
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">{repository?.name || 'Repository'}</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/projects/${projectId}/repositories`)}
|
||||||
|
className="text-indigo-600 hover:text-indigo-900"
|
||||||
|
>
|
||||||
|
Back to Repositories
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{repository && (
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Git URL</label>
|
||||||
|
<p className="mt-1 text-sm text-gray-900">{repository.git_url}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Provider</label>
|
||||||
|
<p className="mt-1 text-sm text-gray-900">{repository.provider_type}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Default Branch</label>
|
||||||
|
<p className="mt-1 text-sm text-gray-900">{repository.default_branch}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Create Connection</h2>
|
||||||
|
<form onSubmit={handleCreateConnection} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Provider</label>
|
||||||
|
<select
|
||||||
|
value={providerKind}
|
||||||
|
onChange={(e) => setProviderKind(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="github">GitHub</option>
|
||||||
|
<option value="gitlab">GitLab</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Access Token</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={credentialPayload}
|
||||||
|
onChange={(e) => setCredentialPayload(e.target.value)}
|
||||||
|
placeholder="ghp_xxxxxxxxxxxx"
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createConnectionMutation.isPending}
|
||||||
|
className="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createConnectionMutation.isPending ? 'Creating...' : 'Create Connection'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Connections</h2>
|
||||||
|
{repoConnections?.length === 0 ? (
|
||||||
|
<p className="text-gray-500">No connections yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{repoConnections?.map((conn) => (
|
||||||
|
<div
|
||||||
|
key={conn.id}
|
||||||
|
className="flex items-center justify-between p-4 border rounded-lg"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{conn.provider_kind}</p>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
conn.connection_status === 'connected'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: conn.connection_status === 'error'
|
||||||
|
? 'bg-red-100 text-red-800'
|
||||||
|
: 'bg-yellow-100 text-yellow-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{conn.connection_status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => generateSshKeyMutation.mutate(conn.id)}
|
||||||
|
className="text-indigo-600 hover:text-indigo-900"
|
||||||
|
>
|
||||||
|
Generate SSH Key
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteConnectionMutation.mutate(conn.id)}
|
||||||
|
className="text-red-600 hover:text-red-900"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSshKey && generateSshKeyMutation.data && (
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">SSH Public Key</h2>
|
||||||
|
<pre className="bg-gray-100 p-4 rounded text-sm overflow-x-auto">
|
||||||
|
{generateSshKeyMutation.data.public_key}
|
||||||
|
</pre>
|
||||||
|
<p className="text-sm text-gray-600 mt-2">
|
||||||
|
Add this key to your repository's deploy keys.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { api } from '../api/client.ts'
|
||||||
|
|
||||||
|
export default function RepositoryListPage() {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [gitUrl, setGitUrl] = useState('')
|
||||||
|
const [providerType, setProviderType] = useState('github')
|
||||||
|
|
||||||
|
const { data: repositories, isLoading } = useQuery({
|
||||||
|
queryKey: ['repositories', projectId],
|
||||||
|
queryFn: () => api.getRepositories(projectId!),
|
||||||
|
enabled: !!projectId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: { name: string; git_url: string; provider_type: string }) =>
|
||||||
|
api.createRepository(projectId!, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['repositories', projectId] })
|
||||||
|
setName('')
|
||||||
|
setGitUrl('')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (repoId: string) => api.deleteRepository(projectId!, repoId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['repositories', projectId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
createMutation.mutate({ name, git_url: gitUrl, provider_type: providerType })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return <div>Loading repositories...</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Repositories</h1>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 bg-white p-6 rounded-lg shadow">
|
||||||
|
<h2 className="text-lg font-semibold">Add Repository</h2>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Git URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={gitUrl}
|
||||||
|
onChange={(e) => setGitUrl(e.target.value)}
|
||||||
|
placeholder="https://github.com/user/repo.git"
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Provider</label>
|
||||||
|
<select
|
||||||
|
value={providerType}
|
||||||
|
onChange={(e) => setProviderType(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="github">GitHub</option>
|
||||||
|
<option value="gitlab">GitLab</option>
|
||||||
|
<option value="generic">Generic</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? 'Adding...' : 'Add Repository'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="bg-white shadow rounded-lg">
|
||||||
|
<div className="px-4 py-5 sm:p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Repository List</h2>
|
||||||
|
{repositories?.length === 0 ? (
|
||||||
|
<p className="text-gray-500">No repositories yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{repositories?.map((repo) => (
|
||||||
|
<div
|
||||||
|
key={repo.id}
|
||||||
|
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 cursor-pointer"
|
||||||
|
onClick={() => navigate(`/projects/${projectId}/repositories/${repo.id}`)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">{repo.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{repo.git_url}</p>
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
{repo.provider_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
deleteMutation.mutate(repo.id)
|
||||||
|
}}
|
||||||
|
className="text-red-600 hover:text-red-900"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,7 +12,8 @@ import ToolsPage from './pages/ToolsPage'
|
|||||||
import ToolSpawnPage from './pages/ToolSpawnPage'
|
import ToolSpawnPage from './pages/ToolSpawnPage'
|
||||||
import ToolInstanceDetailPage from './pages/ToolInstanceDetailPage'
|
import ToolInstanceDetailPage from './pages/ToolInstanceDetailPage'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import RepositoriesPage from './pages/RepositoriesPage'
|
import RepositoryListPage from './pages/RepositoryListPage'
|
||||||
|
import RepositoryDetailPage from './pages/RepositoryDetailPage'
|
||||||
import ConfigListPage from './pages/ConfigListPage'
|
import ConfigListPage from './pages/ConfigListPage'
|
||||||
import SecretListPage from './pages/SecretListPage'
|
import SecretListPage from './pages/SecretListPage'
|
||||||
|
|
||||||
@@ -37,7 +38,8 @@ export const router = createBrowserRouter([
|
|||||||
{ path: '/tools/spawn', element: <ToolSpawnPage /> },
|
{ path: '/tools/spawn', element: <ToolSpawnPage /> },
|
||||||
{ path: '/projects/:projectId/instances/:instanceId', element: <ToolInstanceDetailPage /> },
|
{ path: '/projects/:projectId/instances/:instanceId', element: <ToolInstanceDetailPage /> },
|
||||||
{ path: '/settings', element: <SettingsPage /> },
|
{ path: '/settings', element: <SettingsPage /> },
|
||||||
{ path: '/repositories', element: <RepositoriesPage /> },
|
{ path: '/repositories', element: <RepositoryListPage /> },
|
||||||
|
{ path: '/projects/:projectId/repositories/:repoId', element: <RepositoryDetailPage /> },
|
||||||
{ path: '/projects/:id/configs', element: <ConfigListPage /> },
|
{ path: '/projects/:id/configs', element: <ConfigListPage /> },
|
||||||
{ path: '/projects/:id/secrets', element: <SecretListPage /> },
|
{ path: '/projects/:id/secrets', element: <SecretListPage /> },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -85,3 +85,36 @@ export interface SecretCreate {
|
|||||||
scope_type: string
|
scope_type: string
|
||||||
scope_id: string
|
scope_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Repository {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
git_url: string
|
||||||
|
provider_type: string
|
||||||
|
default_branch: string
|
||||||
|
project_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepositoryCreate {
|
||||||
|
name: string
|
||||||
|
git_url: string
|
||||||
|
provider_type?: string
|
||||||
|
default_branch?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepositoryConnection {
|
||||||
|
id: string
|
||||||
|
project_id: string
|
||||||
|
repository_id: string | null
|
||||||
|
provider_kind: string
|
||||||
|
credential_id: string | null
|
||||||
|
connection_status: string
|
||||||
|
default_branch: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepositoryConnectionCreate {
|
||||||
|
repository_id: string
|
||||||
|
provider_kind: string
|
||||||
|
credential_kind: string
|
||||||
|
credential_payload: string
|
||||||
|
}
|
||||||
|
|||||||
+54
-1
@@ -217,7 +217,7 @@ and local CLI operations evolve independently:
|
|||||||
Security rules for the package:
|
Security rules for the package:
|
||||||
- Credential models store **only** `encrypted_payload` — no plaintext `token` or `private_key` fields.
|
- Credential models store **only** `encrypted_payload` — no plaintext `token` or `private_key` fields.
|
||||||
- SSH private keys are encrypted before storage; the field uses `repr=False`.
|
- SSH private keys are encrypted before storage; the field uses `repr=False`.
|
||||||
- Real encryption of the payload is deferred to FN-009; the current placeholder is base64-only.
|
- SSH private keys are encrypted with Fernet before storage.
|
||||||
|
|
||||||
## Tool Spawn Workflow
|
## Tool Spawn Workflow
|
||||||
|
|
||||||
@@ -324,6 +324,59 @@ When a tool instance is spawned:
|
|||||||
|
|
||||||
Before spawning, the system validates that all required secrets exist. If any are missing, the spawn fails with an error message listing the missing secrets.
|
Before spawning, the system validates that all required secrets exist. If any are missing, the spawn fails with an error message listing the missing secrets.
|
||||||
|
|
||||||
|
## Repository Connections
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
The platform supports connecting Git repositories to projects with provider-independent authentication.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
1. **Repository** (`apps/api/app/models/repository.py`):
|
||||||
|
- Stores repository metadata (name, git_url, provider_type, default_branch)
|
||||||
|
- Belongs to a project
|
||||||
|
|
||||||
|
2. **Repository Connection** (`apps/api/app/models/repository_connection.py`):
|
||||||
|
- Links a repository to a Git provider with credentials
|
||||||
|
- Tracks connection status (pending, connected, error, disconnected)
|
||||||
|
- Supports SSH key authentication
|
||||||
|
|
||||||
|
3. **Credential Storage** (`apps/api/app/git/credential_storage.py`):
|
||||||
|
- Database-backed storage for encrypted credentials
|
||||||
|
- Uses Fernet encryption for payload
|
||||||
|
- Supports access tokens and SSH keys
|
||||||
|
|
||||||
|
4. **Provider Adapters** (`apps/api/app/git/providers/`):
|
||||||
|
- GitHubAdapter and GitLabAdapter with URL parsing
|
||||||
|
- Extensible for other providers (Gitea, Forgejo)
|
||||||
|
|
||||||
|
5. **SSH Key Lifecycle** (`apps/api/app/git/ssh_key.py`):
|
||||||
|
- Ed25519 key pair generation
|
||||||
|
- Fernet-encrypted private key storage
|
||||||
|
- Public key available for deploy key registration
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
- `POST /projects/{id}/repositories` — Add repository
|
||||||
|
- `GET /projects/{id}/repositories` — List repositories
|
||||||
|
- `DELETE /projects/{id}/repositories/{id}` — Remove repository
|
||||||
|
- `POST /projects/{id}/repository-connections` — Create connection
|
||||||
|
- `GET /projects/{id}/repository-connections` — List connections
|
||||||
|
- `DELETE /projects/{id}/repository-connections/{id}` — Remove connection
|
||||||
|
- `POST /projects/{id}/repository-connections/{id}/ssh-key` — Generate SSH key
|
||||||
|
- `POST /projects/{id}/repository-connections/{id}/validate` — Validate connection
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- `/repositories` — Repository list and creation
|
||||||
|
- `/projects/{id}/repositories/{id}` — Repository detail with connections
|
||||||
|
|
||||||
|
### Git Operations
|
||||||
|
|
||||||
|
Local Git operations are supported via subprocess:
|
||||||
|
- Clone, fetch, push with credential-aware subprocess
|
||||||
|
- Working tree status (branch, clean, untracked, modified, staged, deleted)
|
||||||
|
|
||||||
## OpenCode Tool
|
## OpenCode Tool
|
||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
## Context
|
|
||||||
|
|
||||||
The backend has Config and Secret models (FN-004) with scope fields, but no frontend UI or runtime injection. SSH keys already use Fernet encryption (FN-011), so the encryption pattern is established. This design completes the config/secrets lifecycle.
|
|
||||||
|
|
||||||
Current state:
|
|
||||||
- Config model: key, value, scope (global/user/project/instance), scope_id
|
|
||||||
- Secret model: key, encrypted_value, scope, scope_id
|
|
||||||
- Fernet encryption utilities exist in app/encryption.py
|
|
||||||
- No UI for management
|
|
||||||
- No runtime injection into containers
|
|
||||||
|
|
||||||
## Goals / Non-Goals
|
|
||||||
|
|
||||||
**Goals:**
|
|
||||||
- Allow users to manage configs and secrets via UI
|
|
||||||
- Inject configs/secrets into tool containers at spawn time
|
|
||||||
- Support scope-based inheritance (instance overrides project overrides user overrides global)
|
|
||||||
- Maintain encryption for all secret values
|
|
||||||
|
|
||||||
**Non-Goals:**
|
|
||||||
- Secret versioning or history
|
|
||||||
- Automatic secret rotation
|
|
||||||
- Integration with external secret managers (Vault, AWS Secrets Manager)
|
|
||||||
- Config/secrets for non-tool resources
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
**1. Mount configs as files, secrets as env vars**
|
|
||||||
- Rationale: Configs (JSON) are often files (e.g., settings.json). Secrets are typically env vars.
|
|
||||||
- Config mount: `/app/config/<key>.json`
|
|
||||||
- Secret env: `<KEY>=<decrypted_value>`
|
|
||||||
|
|
||||||
**2. Scope resolution: closest match wins**
|
|
||||||
- Rationale: Instance-specific values should override project defaults
|
|
||||||
- Resolution order: instance → project → user → global
|
|
||||||
|
|
||||||
**3. Secret values never sent to frontend decrypted**
|
|
||||||
- Rationale: Security. Frontend only sees masked values (e.g., `••••••`).
|
|
||||||
- Decryption happens only in backend during runtime injection
|
|
||||||
|
|
||||||
**4. Config values are plaintext (not encrypted)**
|
|
||||||
- Rationale: Configs are not sensitive. Encrypting them adds complexity without security benefit.
|
|
||||||
|
|
||||||
## Risks / Trade-offs
|
|
||||||
|
|
||||||
**[Risk] Secret injection at spawn time could fail silently**
|
|
||||||
→ Mitigation: Validate all referenced secrets exist before spawning. Return error if missing.
|
|
||||||
|
|
||||||
**[Risk] Config files in containers could be read by other processes**
|
|
||||||
→ Mitigation: Mount config files with restrictive permissions (0400). Run containers as non-root.
|
|
||||||
|
|
||||||
**[Risk] Large configs could exceed container env var limits**
|
|
||||||
→ Mitigation: Document size limits. Consider config file mounting for large values.
|
|
||||||
|
|
||||||
## Migration Plan
|
|
||||||
|
|
||||||
No migration needed. This extends existing models.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
1. Should configs support JSON schema validation?
|
|
||||||
2. Do we need bulk import/export for configs/secrets?
|
|
||||||
3. Should secret keys be validated against a naming convention?
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
## Why
|
|
||||||
|
|
||||||
Tool instances need runtime configuration and secrets (API keys, database passwords, etc.). The backend has Config and Secret models (FN-004), but there's no UI for users to manage these values, and no runtime injection mechanism to pass them into spawned containers.
|
|
||||||
|
|
||||||
## What Changes
|
|
||||||
|
|
||||||
- **Config management UI**: Frontend pages for creating, updating, and deleting config values at global/user/project/instance scopes
|
|
||||||
- **Secret management UI**: Frontend pages for encrypted secret storage with masked value display
|
|
||||||
- **Runtime injection**: Backend service that mounts configs and secrets into tool containers at spawn time
|
|
||||||
- **Scope-based access control**: Configs/secrets respect scope hierarchy (global → user → project → instance)
|
|
||||||
- **Encryption verification**: Ensure Fernet encryption is properly applied to all secret values
|
|
||||||
|
|
||||||
## Capabilities
|
|
||||||
|
|
||||||
### New Capabilities
|
|
||||||
- `config-management`: CRUD operations for configuration values with scope support
|
|
||||||
- `secret-management`: Encrypted storage and retrieval of sensitive values
|
|
||||||
- `runtime-injection`: Mount configs and secrets into tool containers at spawn
|
|
||||||
|
|
||||||
### Modified Capabilities
|
|
||||||
- None (extends existing Config/Secret models)
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- **apps/web/src/**: New config and secret management pages
|
|
||||||
- **apps/api/app/routers/configs.py**: Enhanced with scope filtering
|
|
||||||
- **apps/api/app/routers/secrets.py**: Enhanced with scope filtering
|
|
||||||
- **apps/api/app/services/**: New runtime injection service
|
|
||||||
- **apps/api/app/models/**: Potential Config/Secret model updates for scope validation
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: User can create config values
|
|
||||||
The system SHALL allow users to create configuration values at various scopes.
|
|
||||||
|
|
||||||
#### Scenario: Create project config
|
|
||||||
- **WHEN** the user navigates to project settings
|
|
||||||
- **AND** clicks "Add Config"
|
|
||||||
- **THEN** a form appears with key, value, and scope fields
|
|
||||||
- **AND** submitting creates a config at the selected scope
|
|
||||||
|
|
||||||
#### Scenario: Config scope validation
|
|
||||||
- **WHEN** the user creates a config
|
|
||||||
- **THEN** the scope must be one of: global, user, project, instance
|
|
||||||
- **AND** the scope_id must match the selected scope type
|
|
||||||
|
|
||||||
### Requirement: User can view and update configs
|
|
||||||
The system SHALL display configs with scope-based filtering.
|
|
||||||
|
|
||||||
#### Scenario: List configs
|
|
||||||
- **WHEN** the user views configs for a project
|
|
||||||
- **THEN** all configs visible at project scope or above are displayed
|
|
||||||
- **AND** values are shown as formatted JSON
|
|
||||||
|
|
||||||
#### Scenario: Update config
|
|
||||||
- **WHEN** the user edits a config value
|
|
||||||
- **THEN** the updated value is saved
|
|
||||||
- **AND** the change takes effect on next tool spawn
|
|
||||||
|
|
||||||
### Requirement: User can delete configs
|
|
||||||
The system SHALL allow deletion of config values.
|
|
||||||
|
|
||||||
#### Scenario: Delete config
|
|
||||||
- **WHEN** the user clicks delete on a config
|
|
||||||
- **THEN** a confirmation dialog appears
|
|
||||||
- **AND** confirming removes the config
|
|
||||||
- **AND** the config is no longer injected into containers
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: Configs are mounted into tool containers
|
|
||||||
The system SHALL mount configuration values as files into spawned tool containers.
|
|
||||||
|
|
||||||
#### Scenario: Config file mount
|
|
||||||
- **WHEN** a tool instance is spawned
|
|
||||||
- **THEN** all applicable configs are written to /app/config/
|
|
||||||
- **AND** each config is a separate JSON file named by key
|
|
||||||
- **AND** files have restrictive permissions (0400)
|
|
||||||
|
|
||||||
#### Scenario: Config scope resolution
|
|
||||||
- **WHEN** configs are resolved for a tool instance
|
|
||||||
- **THEN** the system collects configs from all applicable scopes
|
|
||||||
- **AND** instance scope overrides project scope
|
|
||||||
- **AND** project scope overrides user scope
|
|
||||||
- **AND** user scope overrides global scope
|
|
||||||
|
|
||||||
### Requirement: Secrets are injected as environment variables
|
|
||||||
The system SHALL inject secret values as environment variables into tool containers.
|
|
||||||
|
|
||||||
#### Scenario: Secret env var injection
|
|
||||||
- **WHEN** a tool instance is spawned
|
|
||||||
- **THEN** all applicable secrets are decrypted
|
|
||||||
- **AND** injected as environment variables with uppercase keys
|
|
||||||
- **AND** the container process can access them
|
|
||||||
|
|
||||||
#### Scenario: Secret scope resolution
|
|
||||||
- **WHEN** secrets are resolved for a tool instance
|
|
||||||
- **THEN** the same scope hierarchy applies as configs
|
|
||||||
- **AND** closest scope wins on key collision
|
|
||||||
|
|
||||||
### Requirement: Missing secrets fail spawn
|
|
||||||
The system SHALL prevent spawning if referenced secrets are missing.
|
|
||||||
|
|
||||||
#### Scenario: Validate secrets before spawn
|
|
||||||
- **WHEN** a spawn request references a secret by key
|
|
||||||
- **AND** the secret does not exist in any applicable scope
|
|
||||||
- **THEN** the spawn fails with a clear error message
|
|
||||||
- **AND** no container is created
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: User can create secrets
|
|
||||||
The system SHALL allow users to store encrypted secret values.
|
|
||||||
|
|
||||||
#### Scenario: Create secret
|
|
||||||
- **WHEN** the user navigates to project secrets
|
|
||||||
- **AND** clicks "Add Secret"
|
|
||||||
- **THEN** a form appears with key and value fields
|
|
||||||
- **AND** the value is encrypted with Fernet before storage
|
|
||||||
- **AND** the user sees a masked value (e.g., ••••••) after creation
|
|
||||||
|
|
||||||
#### Scenario: Secret scope
|
|
||||||
- **WHEN** the user creates a secret
|
|
||||||
- **THEN** the scope can be user, project, or instance
|
|
||||||
- **AND** the secret is only visible within that scope hierarchy
|
|
||||||
|
|
||||||
### Requirement: Secrets are never exposed decrypted
|
|
||||||
The system SHALL prevent decrypted secret values from being sent to the frontend.
|
|
||||||
|
|
||||||
#### Scenario: Secret list display
|
|
||||||
- **WHEN** the user views the secrets list
|
|
||||||
- **THEN** only secret keys and scopes are visible
|
|
||||||
- **AND** values are always masked
|
|
||||||
|
|
||||||
#### Scenario: Secret update
|
|
||||||
- **WHEN** the user updates a secret
|
|
||||||
- **THEN** only the new value is sent to the backend
|
|
||||||
- **AND** the old value is replaced (not displayed)
|
|
||||||
|
|
||||||
### Requirement: User can delete secrets
|
|
||||||
The system SHALL allow deletion of secret values.
|
|
||||||
|
|
||||||
#### Scenario: Delete secret
|
|
||||||
- **WHEN** the user deletes a secret
|
|
||||||
- **THEN** the encrypted value is permanently removed
|
|
||||||
- **AND** the secret is no longer injected into containers
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
## 1. Backend Enhancements
|
|
||||||
|
|
||||||
- [x] 1.1 Update Config model with scope validation methods
|
|
||||||
- [x] 1.2 Update Secret model with encryption verification
|
|
||||||
- [x] 1.3 Enhance configs router with scope filtering and hierarchy resolution
|
|
||||||
- [x] 1.4 Enhance secrets router with scope filtering and hierarchy resolution
|
|
||||||
- [x] 1.5 Create apps/api/app/services/runtime_injection.py for config/secret resolution
|
|
||||||
- [x] 1.6 Implement config file generation for container mounts
|
|
||||||
- [x] 1.7 Implement secret env var generation for container injection
|
|
||||||
- [x] 1.8 Add validation to fail spawn when referenced secrets are missing
|
|
||||||
|
|
||||||
## 2. Frontend - Config Management
|
|
||||||
|
|
||||||
- [x] 2.1 Create ConfigList component at /projects/:id/configs
|
|
||||||
- [x] 2.2 Implement ConfigForm for creating/updating configs
|
|
||||||
- [x] 2.3 Add scope selector (project/instance/global) to config form
|
|
||||||
- [x] 2.4 Implement config delete with confirmation
|
|
||||||
- [x] 2.5 Add JSON formatting for config values
|
|
||||||
|
|
||||||
## 3. Frontend - Secret Management
|
|
||||||
|
|
||||||
- [x] 3.1 Create SecretList component at /projects/:id/secrets
|
|
||||||
- [x] 3.2 Implement SecretForm for creating/updating secrets
|
|
||||||
- [x] 3.3 Add masked value display (never show decrypted)
|
|
||||||
- [x] 3.4 Implement secret delete with confirmation
|
|
||||||
- [x] 3.5 Add scope selector to secret form
|
|
||||||
|
|
||||||
## 4. Runtime Integration
|
|
||||||
|
|
||||||
- [x] 4.1 Integrate runtime injection into tool instance spawn endpoint
|
|
||||||
- [x] 4.2 Update Docker Compose generation to include config mounts
|
|
||||||
- [x] 4.3 Update Docker Compose generation to include secret env vars
|
|
||||||
- [x] 4.4 Test config/secret injection in local Docker environment
|
|
||||||
|
|
||||||
## 5. Testing & Verification
|
|
||||||
|
|
||||||
- [x] 5.1 Write backend tests for config scope resolution
|
|
||||||
- [x] 5.2 Write backend tests for secret encryption/decryption
|
|
||||||
- [x] 5.3 Write backend tests for runtime injection
|
|
||||||
- [x] 5.4 Write frontend tests for ConfigList and SecretList
|
|
||||||
- [x] 5.5 Run full test suite: `make test`
|
|
||||||
- [x] 5.6 Run linters: `make lint`
|
|
||||||
|
|
||||||
## 6. Documentation
|
|
||||||
|
|
||||||
- [x] 6.1 Update docs/development.md with config/secrets workflow
|
|
||||||
- [x] 6.2 Add config/secrets UI guide to docs/architecture.md
|
|
||||||
- [x] 6.3 Document scope hierarchy and resolution rules
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
## Context
|
|
||||||
|
|
||||||
The platform routes tool instances via Traefik using subdomain patterns like `https://{tool}-{project}-{user}.{tool_domain}`. Currently, there's no automated label generation or production deployment configuration. This design establishes the deployment architecture.
|
|
||||||
|
|
||||||
Current state:
|
|
||||||
- `docker-compose.yml` for local dev only
|
|
||||||
- `docker-compose.traefik.yml` exists but is minimal
|
|
||||||
- `deploy/` directory has skeleton files
|
|
||||||
- No automated Traefik label generation
|
|
||||||
|
|
||||||
## Goals / Non-Goals
|
|
||||||
|
|
||||||
**Goals:**
|
|
||||||
- Generate Traefik labels automatically when spawning tools
|
|
||||||
- Provide production-ready Docker Compose stack
|
|
||||||
- Support Portainer-managed deployment
|
|
||||||
- Enable HTTPS with automatic certificate management
|
|
||||||
|
|
||||||
**Non-Goals:**
|
|
||||||
- Kubernetes deployment (deferred post-MVP)
|
|
||||||
- Multi-region or high-availability setup
|
|
||||||
- Custom reverse proxy (Traefik is the only supported option)
|
|
||||||
- Automatic DNS management
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
**1. Label generation in backend, not in Docker Compose**
|
|
||||||
- Rationale: Backend has all metadata (user slug, project slug, tool ID). Generating labels at spawn time is more flexible than static Compose files.
|
|
||||||
- Implementation: `TraefikLabelGenerator` service class
|
|
||||||
|
|
||||||
**2. Subdomain pattern: `{tool}-{project}-{user}.{domain}`**
|
|
||||||
- Rationale: Unique, deterministic, human-readable
|
|
||||||
- Example: `code-server-myapp-alice.headquarter.example.com`
|
|
||||||
|
|
||||||
**3. Separate Docker networks: `platform` and `tools`**
|
|
||||||
- Rationale: Network isolation between platform services and user tools
|
|
||||||
- Platform network: API, web, Traefik, database
|
|
||||||
- Tools network: Traefik + tool containers only
|
|
||||||
|
|
||||||
**4. Portainer as the deployment target**
|
|
||||||
- Rationale: Docker Compose-native, web UI for operators, supports stacks and webhooks
|
|
||||||
- Alternative: Raw Docker Compose on VM - less operator-friendly
|
|
||||||
|
|
||||||
**5. Let's Encrypt for HTTPS in production**
|
|
||||||
- Rationale: Free, automatic, Traefik has built-in support
|
|
||||||
- Alternative: Custom certificates - adds operational burden
|
|
||||||
|
|
||||||
## Risks / Trade-offs
|
|
||||||
|
|
||||||
**[Risk] Traefik label complexity grows with features**
|
|
||||||
→ Mitigation: Keep label generation centralized in one service class. Test label output against Traefik schema.
|
|
||||||
|
|
||||||
**[Risk] Portainer stack updates require downtime**
|
|
||||||
→ Mitigation: Use rolling updates where possible. Document blue-green deployment strategy.
|
|
||||||
|
|
||||||
**[Risk] Subdomain collision**
|
|
||||||
→ Mitigation: Enforce unique project slugs per user. Include user slug in subdomain.
|
|
||||||
|
|
||||||
## Migration Plan
|
|
||||||
|
|
||||||
No migration - new deployment stack is additive.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
1. Should we support custom domains per user/project in MVP?
|
|
||||||
2. Do we need basic auth or IP allow-listing for Traefik dashboard?
|
|
||||||
3. Should tool containers run on a separate Docker daemon for security?
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
## Why
|
|
||||||
|
|
||||||
The scaffold provides local Docker Compose development (FN-002) but lacks production deployment configuration. Without Traefik label generation and production stacks, tool instances cannot receive HTTPS subdomains, blocking the core value proposition of the platform.
|
|
||||||
|
|
||||||
## What Changes
|
|
||||||
|
|
||||||
- **Traefik label generator**: Backend service that generates Docker labels for subdomain routing based on tool instance metadata
|
|
||||||
- **Production Docker Compose stack**: `docker-compose.prod.yml` with API, web, Traefik, and PostgreSQL services
|
|
||||||
- **Portainer stack definition**: Docker Compose file optimized for Portainer deployment
|
|
||||||
- **Dynamic subdomain routing**: Automatic Traefik rule generation for spawned tool containers
|
|
||||||
- **HTTPS configuration**: Let's Encrypt or custom certificate support via Traefik
|
|
||||||
- **Network isolation**: Separate Docker networks for platform and tool containers
|
|
||||||
|
|
||||||
## Capabilities
|
|
||||||
|
|
||||||
### New Capabilities
|
|
||||||
- `traefik-label-generator`: Generate Traefik Docker labels for tool subdomain routing
|
|
||||||
- `production-compose-stack`: Production Docker Compose configuration
|
|
||||||
- `portainer-deployment`: Portainer-friendly stack definition and deployment guide
|
|
||||||
- `subdomain-routing`: Dynamic HTTPS subdomain allocation for tool instances
|
|
||||||
|
|
||||||
### Modified Capabilities
|
|
||||||
- None (this extends the existing deployment skeleton)
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- **apps/api/app/services/**: New Traefik label generation service
|
|
||||||
- **apps/api/app/routers/tool_instances.py**: Integrate label generation on spawn
|
|
||||||
- **deploy/**: New production deployment files
|
|
||||||
- **docker-compose.prod.yml**: Production stack definition
|
|
||||||
- **docs/deployment.md**: Updated deployment instructions
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: Stack deploys via Portainer
|
|
||||||
The system SHALL provide a Portainer-compatible stack definition.
|
|
||||||
|
|
||||||
#### Scenario: Portainer stack file
|
|
||||||
- **WHEN** an operator deploys via Portainer
|
|
||||||
- **THEN** they can paste the stack definition into Portainer's stack editor
|
|
||||||
- **AND** Portainer can pull and deploy all services
|
|
||||||
|
|
||||||
#### Scenario: Environment variables in Portainer
|
|
||||||
- **WHEN** the stack is deployed via Portainer
|
|
||||||
- **THEN** environment variables are configured in Portainer's UI
|
|
||||||
- **AND** the stack references these variables
|
|
||||||
|
|
||||||
### Requirement: Deployment documentation is complete
|
|
||||||
The system SHALL provide operator documentation for deployment.
|
|
||||||
|
|
||||||
#### Scenario: Deployment guide
|
|
||||||
- **WHEN** an operator reads docs/deployment.md
|
|
||||||
- **THEN** they find step-by-step instructions for Portainer deployment
|
|
||||||
- **AND** prerequisites and assumptions are clearly stated
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: Production stack includes all required services
|
|
||||||
The system SHALL provide a production Docker Compose stack with API, web, Traefik, and PostgreSQL.
|
|
||||||
|
|
||||||
#### Scenario: Stack services
|
|
||||||
- **WHEN** the production stack is deployed
|
|
||||||
- **THEN** the following services run: api, web, traefik, db
|
|
||||||
- **AND** Traefik routes requests to the appropriate service
|
|
||||||
- **AND** services communicate via isolated Docker networks
|
|
||||||
|
|
||||||
#### Scenario: Environment configuration
|
|
||||||
- **WHEN** the stack starts
|
|
||||||
- **THEN** it reads environment variables from .env
|
|
||||||
- **AND** sensitive values are not hardcoded
|
|
||||||
|
|
||||||
### Requirement: Production stack is secure by default
|
|
||||||
The system SHALL configure security headers and access controls in production.
|
|
||||||
|
|
||||||
#### Scenario: HTTPS only
|
|
||||||
- **WHEN** the stack runs in production
|
|
||||||
- **THEN** all traffic uses HTTPS
|
|
||||||
- **AND** HTTP redirects to HTTPS
|
|
||||||
|
|
||||||
#### Scenario: Network isolation
|
|
||||||
- **WHEN** the stack is deployed
|
|
||||||
- **THEN** platform services and tool containers are on separate networks
|
|
||||||
- **AND** tool containers cannot access the database directly
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: Each tool instance gets a unique subdomain
|
|
||||||
The system SHALL assign a unique HTTPS subdomain to each running tool instance.
|
|
||||||
|
|
||||||
#### Scenario: Subdomain pattern
|
|
||||||
- **WHEN** a tool instance is spawned
|
|
||||||
- **THEN** its subdomain follows `{tool}-{project}-{user}.{domain}`
|
|
||||||
- **AND** the subdomain is deterministic based on instance metadata
|
|
||||||
|
|
||||||
#### Scenario: Subdomain accessibility
|
|
||||||
- **WHEN** a tool instance reaches running status
|
|
||||||
- **THEN** its subdomain resolves via DNS
|
|
||||||
- **AND** Traefik routes the subdomain to the container
|
|
||||||
- **AND** the user can access the tool via the subdomain URL
|
|
||||||
|
|
||||||
### Requirement: Subdomain is released on stop
|
|
||||||
The system SHALL remove Traefik routing when a tool instance stops.
|
|
||||||
|
|
||||||
#### Scenario: Stop removes routing
|
|
||||||
- **WHEN** a tool instance is stopped
|
|
||||||
- **THEN** Traefik labels are removed or disabled
|
|
||||||
- **AND** the subdomain no longer routes to the container
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: Tool spawn generates Traefik labels
|
|
||||||
The system SHALL generate Docker labels for Traefik when spawning a tool instance.
|
|
||||||
|
|
||||||
#### Scenario: Label generation on spawn
|
|
||||||
- **WHEN** a tool instance is spawned
|
|
||||||
- **THEN** the backend generates Traefik router and service labels
|
|
||||||
- **AND** labels include rule, service, port, and TLS configuration
|
|
||||||
- **AND** labels are stored with the tool instance metadata
|
|
||||||
|
|
||||||
#### Scenario: Label format
|
|
||||||
- **WHEN** labels are generated for a tool instance
|
|
||||||
- **THEN** router rule uses Host(`{subdomain}.{domain}`)
|
|
||||||
- **AND** service points to the container's exposed port
|
|
||||||
- **AND** TLS is enabled with certResolver
|
|
||||||
|
|
||||||
### Requirement: Label generation handles multiple instances
|
|
||||||
The system SHALL generate unique labels for each tool instance.
|
|
||||||
|
|
||||||
#### Scenario: Unique router names
|
|
||||||
- **WHEN** multiple instances of the same tool exist
|
|
||||||
- **THEN** each instance gets a unique router name
|
|
||||||
- **AND** no label collisions occur
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
## 1. Traefik Label Generator
|
|
||||||
|
|
||||||
- [x] 1.1 Create apps/api/app/services/traefik.py with TraefikLabelGenerator class
|
|
||||||
- [x] 1.2 Implement subdomain generation from tool_id, project_slug, user_slug
|
|
||||||
- [x] 1.3 Generate router labels (rule, service, tls)
|
|
||||||
- [x] 1.4 Generate service labels (loadBalancer, port)
|
|
||||||
- [x] 1.5 Add middleware labels for security headers
|
|
||||||
- [x] 1.6 Write unit tests for label generation
|
|
||||||
|
|
||||||
## 2. Backend Integration
|
|
||||||
|
|
||||||
- [x] 2.1 Integrate label generation into tool instance spawn endpoint
|
|
||||||
- [x] 2.2 Store generated labels in tool_instance metadata
|
|
||||||
- [x] 2.3 Remove/disable labels on tool instance stop
|
|
||||||
- [x] 2.4 Update ToolInstance model to store labels JSON
|
|
||||||
|
|
||||||
## 3. Production Docker Compose
|
|
||||||
|
|
||||||
- [x] 3.1 Create docker-compose.prod.yml with api, web, traefik, db services
|
|
||||||
- [x] 3.2 Configure Traefik service with Let's Encrypt certificates
|
|
||||||
- [x] 3.3 Set up platform and tools networks
|
|
||||||
- [x] 3.4 Add health checks for all services
|
|
||||||
- [x] 3.5 Configure logging (JSON format, rotation)
|
|
||||||
|
|
||||||
## 4. Portainer Deployment
|
|
||||||
|
|
||||||
- [x] 4.1 Create deploy/portainer-stack.yml
|
|
||||||
- [x] 4.2 Add Portainer-specific environment variable documentation
|
|
||||||
- [x] 4.3 Create deploy/.env.example for production
|
|
||||||
- [x] 4.4 Test stack deployment locally with docker compose -f docker-compose.prod.yml
|
|
||||||
|
|
||||||
## 5. Documentation
|
|
||||||
|
|
||||||
- [x] 5.1 Update docs/deployment.md with production deployment steps
|
|
||||||
- [x] 5.2 Add Traefik configuration guide
|
|
||||||
- [x] 5.3 Document subdomain scheme and DNS requirements
|
|
||||||
- [x] 5.4 Update README.md with deployment section
|
|
||||||
|
|
||||||
## 6. Testing & Verification
|
|
||||||
|
|
||||||
- [x] 6.1 Test label generation for all built-in tools
|
|
||||||
- [x] 6.2 Verify Traefik routes correctly in local stack
|
|
||||||
- [x] 6.3 Run backend tests: `cd apps/api && pytest`
|
|
||||||
- [x] 6.4 Run linters: `ruff check app/` and `mypy app/`
|
|
||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
schema: spec-driven
|
schema: spec-driven
|
||||||
created: 2026-05-14
|
created: 2026-05-16
|
||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
schema: spec-driven
|
schema: spec-driven
|
||||||
created: 2026-05-14
|
created: 2026-05-16
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The platform has a Git abstraction layer in `apps/api/app/git/` with:
|
||||||
|
- `provider.py`: GitProvider ABC (validate_connection, list_repos, create_deploy_key, delete_deploy_key, get_default_branch)
|
||||||
|
- `connection.py`: ConnectionManager (connect/disconnect/get_connection)
|
||||||
|
- `credentials.py`: GitCredential, AccessTokenCredential, CredentialStorage ABC
|
||||||
|
- `ssh_key.py`: SshKeyPair, SshKeyLifecycle with Ed25519 generation
|
||||||
|
- `operations.py`: GitOperations ABC, LocalGitOperations (clone/fetch/push are NotImplementedError)
|
||||||
|
- `types.py`: ProviderKind, CredentialKind, ConnectionStatus, SshKeyStatus enums
|
||||||
|
|
||||||
|
Current gaps:
|
||||||
|
- No concrete provider adapters (GitHub, GitLab)
|
||||||
|
- CredentialStorage has no database implementation
|
||||||
|
- LocalGitOperations is incomplete
|
||||||
|
- No RepositoryConnection router or API endpoints
|
||||||
|
- SSH key generation uses base64 placeholder
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Implement concrete GitHub and GitLab provider adapters
|
||||||
|
- Create database-backed credential storage with encryption
|
||||||
|
- Complete LocalGitOperations with credential-aware subprocess
|
||||||
|
- Add RepositoryConnection router with CRUD endpoints
|
||||||
|
- Generate Ed25519 SSH keys and register deploy keys
|
||||||
|
- Frontend UI for repository connections and SSH key management
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Support for Gitea/Forgejo (deferred post-MVP)
|
||||||
|
- GitHub/GitLab OAuth app integration (use personal access tokens)
|
||||||
|
- Webhook management
|
||||||
|
- Repository mirroring
|
||||||
|
- Branch protection management
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
**1. Use httpx for provider API calls**
|
||||||
|
- Rationale: Already in dependencies, async support, consistent with FastAPI
|
||||||
|
- Alternative: requests - blocking, would need thread pool
|
||||||
|
|
||||||
|
**2. Store credentials encrypted with Fernet (same as secrets)**
|
||||||
|
- Rationale: Consistent with existing secret storage in FN-009
|
||||||
|
- Implementation: Reuse encryption service from app/encryption.py
|
||||||
|
|
||||||
|
**3. SSH keys generated per-repository (not per-user)**
|
||||||
|
- Rationale: Fine-grained access control, easy revocation per repo
|
||||||
|
- Alternative: Per-user keys - broader blast radius on compromise
|
||||||
|
|
||||||
|
**4. Provider adapters implement GitProvider ABC**
|
||||||
|
- Rationale: Clean abstraction, easy to add new providers
|
||||||
|
- Implementation: GitHubAdapter, GitLabAdapter with unified interface
|
||||||
|
|
||||||
|
**5. Git operations use subprocess with SSH key in temp file**
|
||||||
|
- Rationale: Standard git CLI is most reliable
|
||||||
|
- Implementation: Write key to temp file, set GIT_SSH_COMMAND env var
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
**[Risk] Personal access tokens have broad permissions**
|
||||||
|
→ Mitigation: Document minimal required scopes (repo read/write, deploy key management)
|
||||||
|
|
||||||
|
**[Risk] SSH keys in temp files are briefly exposed on disk**
|
||||||
|
→ Mitigation: Use 0600 permissions, clean up immediately after operation
|
||||||
|
|
||||||
|
**[Risk] Provider API rate limits**
|
||||||
|
→ Mitigation: Cache repository lists, implement exponential backoff
|
||||||
|
|
||||||
|
**[Risk] Token storage compromise**
|
||||||
|
→ Mitigation: Fernet encryption with rotation support
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
No migration. New feature.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. Should we support SSH key passphrases?
|
||||||
|
2. Do we need to validate repository URLs before connection?
|
||||||
|
3. Should we auto-detect provider from URL?
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The platform needs a provider-independent Git connection model so users can connect repositories from GitHub, GitLab, Gitea, or Forgejo without vendor lock-in. Currently, the Git abstraction layer exists but lacks concrete provider adapters, credential storage, and API endpoints for managing connections. This is the foundation for repository cloning, branch management, and automated deploy key registration.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Provider adapter framework**: Concrete implementations for GitHub and GitLab APIs with unified interface
|
||||||
|
- **Credential storage backend**: Database-backed storage for access tokens and SSH keys with encryption
|
||||||
|
- **Repository connection API**: REST endpoints for creating, listing, and deleting repository connections
|
||||||
|
- **SSH key lifecycle**: Ed25519 key generation, public key retrieval, and deploy key registration
|
||||||
|
- **Git operations**: Complete LocalGitOperations with credential-aware clone, fetch, and push
|
||||||
|
- **Frontend repository UI**: Interface for connecting repositories and managing SSH keys
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `git-provider-adapter`: Unified interface for Git provider APIs (GitHub, GitLab)
|
||||||
|
- `credential-storage`: Encrypted storage for access tokens and SSH keys
|
||||||
|
- `repository-connection`: API for managing repository connections
|
||||||
|
- `ssh-key-lifecycle`: SSH key generation and deploy key management
|
||||||
|
- `git-operations`: Credential-aware Git operations (clone, fetch, push)
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- None (extends existing Git abstraction)
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **apps/api/app/git/**: New provider adapters and completed operations
|
||||||
|
- **apps/api/app/routers/repository_connections.py**: New router
|
||||||
|
- **apps/api/app/models/repository_connection.py**: Enhanced model
|
||||||
|
- **apps/api/app/schemas/repository_connection.py**: New schemas
|
||||||
|
- **apps/web/src/pages/RepositoriesPage.tsx**: Enhanced UI
|
||||||
|
- **apps/web/src/pages/RepositoryConnectionPage.tsx**: New page
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- FN-003: Tool Registry (manifest system)
|
||||||
|
- FN-004: Backend Foundation (models, auth)
|
||||||
|
- FN-009: Config & Secrets (encryption, credential storage)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Credentials are stored encrypted
|
||||||
|
The system SHALL store access tokens and SSH keys encrypted at rest.
|
||||||
|
|
||||||
|
#### Scenario: Store access token
|
||||||
|
- **WHEN** a user saves an access token
|
||||||
|
- **THEN** the system encrypts it with Fernet
|
||||||
|
- **AND** stores the encrypted value in the database
|
||||||
|
|
||||||
|
#### Scenario: Retrieve access token
|
||||||
|
- **WHEN** the system retrieves a credential for API calls
|
||||||
|
- **THEN** it decrypts the value
|
||||||
|
- **AND** returns the plaintext token
|
||||||
|
|
||||||
|
#### Scenario: List credentials without exposing values
|
||||||
|
- **WHEN** a user lists their credentials
|
||||||
|
- **THEN** the system returns metadata (name, provider, created_at)
|
||||||
|
- **AND** masks the token value (showing only last 4 characters)
|
||||||
|
|
||||||
|
### Requirement: Credential storage supports multiple providers
|
||||||
|
The system SHALL support storing credentials for different Git providers.
|
||||||
|
|
||||||
|
#### Scenario: Store GitHub token
|
||||||
|
- **WHEN** a user adds a GitHub personal access token
|
||||||
|
- **THEN** the system stores it with provider_type="github"
|
||||||
|
|
||||||
|
#### Scenario: Store GitLab token
|
||||||
|
- **WHEN** a user adds a GitLab personal access token
|
||||||
|
- **THEN** the system stores it with provider_type="gitlab"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Git clone uses SSH credentials
|
||||||
|
The system SHALL clone repositories using SSH keys.
|
||||||
|
|
||||||
|
#### Scenario: Clone repository
|
||||||
|
- **WHEN** the system clones a repository
|
||||||
|
- **THEN** it writes the SSH private key to a temporary file
|
||||||
|
- **AND** sets GIT_SSH_COMMAND to use the key
|
||||||
|
- **AND** executes git clone
|
||||||
|
- **AND** cleans up the temporary key file
|
||||||
|
|
||||||
|
#### Scenario: Clone fails with invalid key
|
||||||
|
- **WHEN** a clone operation fails due to authentication
|
||||||
|
- **THEN** the system returns a clear error message
|
||||||
|
- **AND** suggests checking deploy key permissions
|
||||||
|
|
||||||
|
### Requirement: Git fetch and push use credentials
|
||||||
|
The system SHALL support fetch and push operations with SSH credentials.
|
||||||
|
|
||||||
|
#### Scenario: Fetch updates
|
||||||
|
- **WHEN** the system fetches from a remote
|
||||||
|
- **THEN** it uses the stored SSH key for authentication
|
||||||
|
- **AND** returns the fetch result
|
||||||
|
|
||||||
|
#### Scenario: Push changes
|
||||||
|
- **WHEN** the system pushes to a remote
|
||||||
|
- **THEN** it uses the stored SSH key for authentication
|
||||||
|
- **AND** returns the push result
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: GitHub adapter implements provider interface
|
||||||
|
The system SHALL provide a GitHub adapter that implements the GitProvider interface.
|
||||||
|
|
||||||
|
#### Scenario: List repositories
|
||||||
|
- **WHEN** the adapter lists repositories for an authenticated user
|
||||||
|
- **THEN** it returns a list of repository objects with name, url, and default_branch
|
||||||
|
|
||||||
|
#### Scenario: Create deploy key
|
||||||
|
- **WHEN** the adapter creates a deploy key for a repository
|
||||||
|
- **THEN** it registers the SSH public key with GitHub
|
||||||
|
- **AND** returns the key ID
|
||||||
|
|
||||||
|
#### Scenario: Validate connection
|
||||||
|
- **WHEN** the adapter validates a token
|
||||||
|
- **THEN** it verifies the token with GitHub API
|
||||||
|
- **AND** returns user information
|
||||||
|
|
||||||
|
### Requirement: GitLab adapter implements provider interface
|
||||||
|
The system SHALL provide a GitLab adapter that implements the GitProvider interface.
|
||||||
|
|
||||||
|
#### Scenario: List repositories
|
||||||
|
- **WHEN** the adapter lists repositories for an authenticated user
|
||||||
|
- **THEN** it returns a list of repository objects with name, url, and default_branch
|
||||||
|
|
||||||
|
#### Scenario: Create deploy key
|
||||||
|
- **WHEN** the adapter creates a deploy key for a repository
|
||||||
|
- **THEN** it registers the SSH public key with GitLab
|
||||||
|
- **AND** returns the key ID
|
||||||
|
|
||||||
|
#### Scenario: Validate connection
|
||||||
|
- **WHEN** the adapter validates a token
|
||||||
|
- **THEN** it verifies the token with GitLab API
|
||||||
|
- **AND** returns user information
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Repository connections can be created
|
||||||
|
The system SHALL allow users to create connections to Git repositories.
|
||||||
|
|
||||||
|
#### Scenario: Connect GitHub repository
|
||||||
|
- **WHEN** a user provides a GitHub repository URL and access token
|
||||||
|
- **THEN** the system validates the URL and token
|
||||||
|
- **AND** creates a RepositoryConnection record
|
||||||
|
- **AND** generates an SSH key pair
|
||||||
|
- **AND** registers the deploy key with GitHub
|
||||||
|
|
||||||
|
#### Scenario: Connect GitLab repository
|
||||||
|
- **WHEN** a user provides a GitLab repository URL and access token
|
||||||
|
- **THEN** the system validates the URL and token
|
||||||
|
- **AND** creates a RepositoryConnection record
|
||||||
|
- **AND** generates an SSH key pair
|
||||||
|
- **AND** registers the deploy key with GitLab
|
||||||
|
|
||||||
|
#### Scenario: Reject invalid URL
|
||||||
|
- **WHEN** a user provides an invalid repository URL
|
||||||
|
- **THEN** the system returns a 400 error with validation message
|
||||||
|
|
||||||
|
### Requirement: Repository connections can be listed and retrieved
|
||||||
|
The system SHALL allow users to list and view their repository connections.
|
||||||
|
|
||||||
|
#### Scenario: List connections
|
||||||
|
- **WHEN** a user requests their repository connections
|
||||||
|
- **THEN** the system returns a list with status and metadata
|
||||||
|
|
||||||
|
#### Scenario: Get connection details
|
||||||
|
- **WHEN** a user requests a specific connection
|
||||||
|
- **THEN** the system returns full details including SSH public key
|
||||||
|
|
||||||
|
### Requirement: Repository connections can be deleted
|
||||||
|
The system SHALL allow users to delete repository connections.
|
||||||
|
|
||||||
|
#### Scenario: Delete connection
|
||||||
|
- **WHEN** a user deletes a connection
|
||||||
|
- **THEN** the system removes the deploy key from the provider
|
||||||
|
- **AND** deletes the SSH key pair
|
||||||
|
- **AND** marks the connection as deleted
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: SSH keys are generated per repository
|
||||||
|
The system SHALL generate Ed25519 SSH key pairs for each repository connection.
|
||||||
|
|
||||||
|
#### Scenario: Generate key pair
|
||||||
|
- **WHEN** a repository connection is created
|
||||||
|
- **THEN** the system generates an Ed25519 key pair
|
||||||
|
- **AND** stores the private key encrypted
|
||||||
|
- **AND** returns the public key for deploy key registration
|
||||||
|
|
||||||
|
#### Scenario: Retrieve public key
|
||||||
|
- **WHEN** a user requests the public key for a connection
|
||||||
|
- **THEN** the system returns the SSH public key string
|
||||||
|
|
||||||
|
### Requirement: SSH keys support lifecycle operations
|
||||||
|
The system SHALL support rotating and revoking SSH keys.
|
||||||
|
|
||||||
|
#### Scenario: Rotate key
|
||||||
|
- **WHEN** a user rotates an SSH key
|
||||||
|
- **THEN** the system generates a new key pair
|
||||||
|
- **AND** updates the deploy key on the provider
|
||||||
|
- **AND** deletes the old key pair
|
||||||
|
|
||||||
|
#### Scenario: Revoke key
|
||||||
|
- **WHEN** a connection is deleted
|
||||||
|
- **THEN** the system deletes the deploy key from the provider
|
||||||
|
- **AND** securely deletes the local key pair
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
## 1. Provider Adapters
|
||||||
|
|
||||||
|
- [ ] 1.1 Implement GitHubAdapter in apps/api/app/git/providers/github.py
|
||||||
|
- [ ] 1.2 Implement GitLabAdapter in apps/api/app/git/providers/gitlab.py
|
||||||
|
- [ ] 1.3 Add provider factory in apps/api/app/git/providers/__init__.py
|
||||||
|
- [ ] 1.4 Write tests for GitHubAdapter (mock API responses)
|
||||||
|
- [ ] 1.5 Write tests for GitLabAdapter (mock API responses)
|
||||||
|
|
||||||
|
## 2. Credential Storage
|
||||||
|
|
||||||
|
- [ ] 2.1 Create CredentialStorage implementation in apps/api/app/git/credentials.py
|
||||||
|
- [ ] 2.2 Add database model for GitCredential if needed
|
||||||
|
- [ ] 2.3 Integrate Fernet encryption from app/encryption.py
|
||||||
|
- [ ] 2.4 Add credential router in apps/api/app/routers/credentials.py
|
||||||
|
- [ ] 2.5 Write tests for credential storage
|
||||||
|
|
||||||
|
## 3. SSH Key Lifecycle
|
||||||
|
|
||||||
|
- [ ] 3.1 Complete SshKeyLifecycle.generate_key_pair() with real Ed25519
|
||||||
|
- [ ] 3.2 Add SSH key endpoints in apps/api/app/routers/ssh_keys.py
|
||||||
|
- [ ] 3.3 Implement key rotation logic
|
||||||
|
- [ ] 3.4 Write tests for SSH key generation
|
||||||
|
|
||||||
|
## 4. Repository Connection API
|
||||||
|
|
||||||
|
- [ ] 4.1 Create RepositoryConnection router in apps/api/app/routers/repository_connections.py
|
||||||
|
- [ ] 4.2 Implement POST /api/v1/repository-connections endpoint
|
||||||
|
- [ ] 4.3 Implement GET /api/v1/repository-connections endpoint
|
||||||
|
- [ ] 4.4 Implement GET /api/v1/repository-connections/:id endpoint
|
||||||
|
- [ ] 4.5 Implement DELETE /api/v1/repository-connections/:id endpoint
|
||||||
|
- [ ] 4.6 Add validation for repository URLs and tokens
|
||||||
|
- [ ] 4.7 Write tests for repository connection endpoints
|
||||||
|
|
||||||
|
## 5. Git Operations
|
||||||
|
|
||||||
|
- [ ] 5.1 Complete LocalGitOperations.clone() with SSH key
|
||||||
|
- [ ] 5.2 Complete LocalGitOperations.fetch() with SSH key
|
||||||
|
- [ ] 5.3 Complete LocalGitOperations.push() with SSH key
|
||||||
|
- [ ] 5.4 Add error handling for auth failures
|
||||||
|
- [ ] 5.5 Write tests for git operations
|
||||||
|
|
||||||
|
## 6. Frontend UI
|
||||||
|
|
||||||
|
- [ ] 6.1 Create RepositoryConnectionListPage.tsx
|
||||||
|
- [ ] 6.2 Create RepositoryConnectionFormPage.tsx
|
||||||
|
- [ ] 6.3 Add repository connection routes to router.tsx
|
||||||
|
- [ ] 6.4 Add API client methods for repository connections
|
||||||
|
- [ ] 6.5 Add types for repository connections
|
||||||
|
|
||||||
|
## 7. Documentation
|
||||||
|
|
||||||
|
- [ ] 7.1 Update docs/architecture.md with Git connection model
|
||||||
|
- [ ] 7.2 Update docs/development.md with setup instructions
|
||||||
|
- [ ] 7.3 Add provider setup guide (GitHub/GitLab tokens)
|
||||||
|
|
||||||
|
## 8. Testing & Verification
|
||||||
|
|
||||||
|
- [ ] 8.1 Run all backend tests (target: 90+)
|
||||||
|
- [ ] 8.2 Run ruff linter
|
||||||
|
- [ ] 8.3 Run mypy type checker
|
||||||
|
- [ ] 8.4 Run frontend tests
|
||||||
|
- [ ] 8.5 Verify API endpoints with manual testing
|
||||||
|
- [ ] 8.6 Update project specsheet
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
schema: spec-driven
|
|
||||||
created: 2026-05-14
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
## Context
|
|
||||||
|
|
||||||
OpenCode is an AI-powered terminal-based development environment with a web interface. Unlike code-server which is a full web IDE, OpenCode provides a terminal experience accessible through the browser. This POC validates that the spawn system handles different runtime types including web terminal forwarding.
|
|
||||||
|
|
||||||
Current state:
|
|
||||||
- OpenCode manifest exists in apps/api/app/tools/manifests/opencode.yml
|
|
||||||
- No container image or runtime defined yet
|
|
||||||
- No health reporting mechanism
|
|
||||||
- Spawn infrastructure will be built in FN-010
|
|
||||||
|
|
||||||
## Goals / Non-Goals
|
|
||||||
|
|
||||||
**Goals:**
|
|
||||||
- Define OpenCode as a spawnable tool
|
|
||||||
- Provide web terminal interface in container
|
|
||||||
- Report health status (running/idle/error)
|
|
||||||
- Support interactive terminal sessions
|
|
||||||
|
|
||||||
**Non-Goals:**
|
|
||||||
- Full task queue or job scheduler
|
|
||||||
- Persistent process management
|
|
||||||
- Log streaming (deferred)
|
|
||||||
- Multi-language support beyond terminal
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
**1. Use official OpenCode Docker image**
|
|
||||||
- Rationale: Maintained, includes AI features and web terminal
|
|
||||||
- Alternative: Custom image - unnecessary for POC
|
|
||||||
|
|
||||||
**2. OpenCode runs as a persistent container**
|
|
||||||
- Rationale: Easier to manage lifecycle (start/stop/status). Terminal sessions need persistent container.
|
|
||||||
- Implementation: Container runs OpenCode with web interface on port 3000
|
|
||||||
|
|
||||||
**3. Health check via HTTP endpoint**
|
|
||||||
- Rationale: Standard Docker health check mechanism. Traefik can use it.
|
|
||||||
- Endpoint: `GET /` returns 200 when ready
|
|
||||||
|
|
||||||
**4. Workspace mounted from host (same as code-server)**
|
|
||||||
- Rationale: Consistency. Shared workspace between tools.
|
|
||||||
- Path: `/data/workspaces/{user_slug}/{project_slug}`
|
|
||||||
|
|
||||||
**5. Configs/secrets injected same as code-server**
|
|
||||||
- Rationale: Reuse FN-009 infrastructure. No special handling needed.
|
|
||||||
|
|
||||||
## Risks / Trade-offs
|
|
||||||
|
|
||||||
**[Risk] OpenCode container requires significant resources**
|
|
||||||
→ Mitigation: Set resource limits (4GB RAM, 2 CPU). Document requirements.
|
|
||||||
|
|
||||||
**[Risk] Web terminal performance over slow connections**
|
|
||||||
→ Mitigation: Use modern terminal emulation with compression. Document bandwidth requirements.
|
|
||||||
|
|
||||||
**[Risk] AI features require API keys**
|
|
||||||
→ Mitigation: Support secret injection for API keys. Document configuration.
|
|
||||||
|
|
||||||
## Migration Plan
|
|
||||||
|
|
||||||
No migration. New feature.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
1. Should OpenCode support multiple terminal sessions?
|
|
||||||
2. Do we pre-configure common development tools?
|
|
||||||
3. Should OpenCode integrate with the platform's AI provider?
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
## Why
|
|
||||||
|
|
||||||
OpenCode is an AI-powered terminal-based development environment that provides a web interface for interactive development. It demonstrates the platform's extensibility beyond standard tools like code-server. As a POC, it validates the manifest-driven spawn system with a non-trivial runtime that requires web terminal forwarding.
|
|
||||||
|
|
||||||
## What Changes
|
|
||||||
|
|
||||||
- **OpenCode manifest**: Define the tool with terminal web interface, workspace mounts, and health checks
|
|
||||||
- **Container image**: Reference to OpenCode image with built-in web terminal
|
|
||||||
- **Health reporting**: Endpoint that reports tool health to the platform
|
|
||||||
- **Spawn integration**: Reuse the spawn flow from FN-010 but with OpenCode-specific configuration
|
|
||||||
- **Web terminal**: Support for browser-based terminal access
|
|
||||||
|
|
||||||
## Capabilities
|
|
||||||
|
|
||||||
### New Capabilities
|
|
||||||
- `opencode-manifest`: OpenCode tool manifest with web terminal config
|
|
||||||
- `web-terminal`: Support for browser-based terminal interfaces
|
|
||||||
- `health-reporting`: Tool health status reporting mechanism
|
|
||||||
|
|
||||||
### Modified Capabilities
|
|
||||||
- None (reuses spawn infrastructure from FN-010)
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- **apps/api/app/tools/manifests/opencode.yml**: Updated manifest
|
|
||||||
- **apps/api/app/services/spawn.py**: Minor updates for OpenCode-specific mounts
|
|
||||||
- **apps/web/src/**: OpenCode appears in tool selection UI
|
|
||||||
- **Docker images**: Uses official OpenCode image
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: Container provides web terminal interface
|
|
||||||
The system SHALL provide a web terminal interface in the OpenCode container.
|
|
||||||
|
|
||||||
#### Scenario: Terminal available
|
|
||||||
- **WHEN** the OpenCode container is running
|
|
||||||
- **THEN** a web terminal is accessible via HTTP on port 3000
|
|
||||||
- **AND** the user can execute shell commands through the browser
|
|
||||||
|
|
||||||
#### Scenario: Workspace access
|
|
||||||
- **WHEN** the container runs
|
|
||||||
- **THEN** the project workspace is mounted at /workspace
|
|
||||||
- **AND** the user can read/write files in the workspace
|
|
||||||
|
|
||||||
### Requirement: Container supports AI features
|
|
||||||
The system SHALL allow AI-powered development features in the OpenCode environment.
|
|
||||||
|
|
||||||
#### Scenario: AI assistance
|
|
||||||
- **WHEN** the user interacts with OpenCode
|
|
||||||
- **THEN** AI features are available for code completion and assistance
|
|
||||||
- **AND** the user can configure AI provider settings
|
|
||||||
|
|
||||||
#### Scenario: Terminal session persistence
|
|
||||||
- **WHEN** the user opens a terminal session
|
|
||||||
- **THEN** the session persists while the container runs
|
|
||||||
- **AND** multiple sessions can be opened
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: Tool reports health status
|
|
||||||
The system SHALL provide a mechanism for OpenCode to report its health.
|
|
||||||
|
|
||||||
#### Scenario: Health endpoint
|
|
||||||
- **WHEN** the OpenCode container is running
|
|
||||||
- **THEN** it exposes a / endpoint for health checks
|
|
||||||
- **AND** returns 200 when the web terminal is ready
|
|
||||||
|
|
||||||
#### Scenario: Health check in Traefik
|
|
||||||
- **WHEN** the container is spawned
|
|
||||||
- **THEN** Traefik uses the health endpoint for routing decisions
|
|
||||||
- **AND** unhealthy containers are removed from the load balancer
|
|
||||||
|
|
||||||
### Requirement: Platform tracks tool health
|
|
||||||
The system SHALL track and display the health of OpenCode instances.
|
|
||||||
|
|
||||||
#### Scenario: Status display
|
|
||||||
- **WHEN** the user views an OpenCode instance
|
|
||||||
- **THEN** the current status is displayed (healthy, unhealthy, starting)
|
|
||||||
- **AND** the status updates automatically
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: OpenCode manifest defines web terminal environment
|
|
||||||
The system SHALL provide an OpenCode manifest with web terminal configuration.
|
|
||||||
|
|
||||||
#### Scenario: Manifest includes terminal config
|
|
||||||
- **WHEN** the OpenCode manifest is loaded
|
|
||||||
- **THEN** it specifies an OpenCode Docker image with web interface
|
|
||||||
- **AND** it defines exposed ports for the HTTP interface (port 3000)
|
|
||||||
- **AND** it defines volume mounts (workspace, config)
|
|
||||||
|
|
||||||
#### Scenario: Manifest includes health check
|
|
||||||
- **WHEN** the manifest is used for spawning
|
|
||||||
- **THEN** it defines a health check endpoint
|
|
||||||
- **AND** specifies health check interval and timeout
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
## 1. Manifest Definition
|
|
||||||
|
|
||||||
- [x] 1.1 Create apps/api/app/tools/manifests/opencode.yml with web terminal config
|
|
||||||
- [x] 1.2 Add Docker image (ghcr.io/opencode-ai/opencode:latest), ports (3000), volumes
|
|
||||||
- [x] 1.3 Add health check configuration to manifest
|
|
||||||
- [x] 1.4 Validate manifest against ToolManifest schema
|
|
||||||
|
|
||||||
## 2. Container Setup
|
|
||||||
|
|
||||||
- [x] 2.1 Verify OpenCode image availability and configuration
|
|
||||||
- [x] 2.2 Document web terminal access pattern
|
|
||||||
- [x] 2.3 Configure environment variables for terminal support
|
|
||||||
- [x] 2.4 Test container locally with docker run
|
|
||||||
- [x] 2.5 Verify web terminal accessibility
|
|
||||||
|
|
||||||
## 3. Spawn Integration
|
|
||||||
|
|
||||||
- [x] 3.1 Verify SpawnService (FN-010) can spawn OpenCode instances
|
|
||||||
- [x] 3.2 Add OpenCode-specific volume mounts (config)
|
|
||||||
- [x] 3.3 Test spawn via API endpoint
|
|
||||||
- [x] 3.4 Verify Traefik routing to OpenCode container
|
|
||||||
|
|
||||||
## 4. Frontend Integration
|
|
||||||
|
|
||||||
- [x] 4.1 Add OpenCode to tool selection dropdown
|
|
||||||
- [x] 4.2 Display OpenCode-specific options in spawn form
|
|
||||||
- [x] 4.3 Show OpenCode instance status in detail page
|
|
||||||
|
|
||||||
## 5. Testing & Verification
|
|
||||||
|
|
||||||
- [x] 5.1 Test terminal availability in spawned container
|
|
||||||
- [x] 5.2 Test web interface accessibility
|
|
||||||
- [x] 5.3 Test health endpoint response
|
|
||||||
- [x] 5.4 Verify workspace mount is accessible
|
|
||||||
- [x] 5.5 Run full test suite: `make test`
|
|
||||||
- [x] 5.6 Run linters: `make lint`
|
|
||||||
|
|
||||||
## 6. Documentation
|
|
||||||
|
|
||||||
- [x] 6.1 Document OpenCode setup in docs/development.md
|
|
||||||
- [x] 6.2 Add OpenCode usage guide
|
|
||||||
- [x] 6.3 Document terminal configuration and AI features
|
|
||||||
Reference in New Issue
Block a user