Compare commits

...

10 Commits

Author SHA1 Message Date
Fusion 61c5554c42 feat(FN-019): draft enhanced architecture.md with all 18 required sections (+4 more)
CI / API CI (push) Failing after 35s
CI / Web CI (push) Failing after 2m11s
Commits merged:
- docs(FN-019): complete Step 6 — documentation index, conversation handoff, project brief, and build config
- test(FN-019): complete Step 4 — doc validation tests, project-brief.md, and fix mvp-scope placeholders
- feat(FN-019): complete Step 3 — draft mvp-scope.md with milestones, dependency order, and open questions
- docs(FN-019): fix auth callback flow, Python syntax, dev bypass clarity, add AccessProvider protocol and type stubs
- feat(FN-019): complete Step 2 — draft enhanced architecture.md with all 18 required sections

Files changed:
docs/README.md                  |   11 +-
 docs/architecture.md            | 1185 ++++++++++++++++++++++++++++++++++-----
 docs/conversation-handoff.md    |   68 +++
 docs/mvp-scope.md               |  184 ++++++
 docs/project-brief.md           |   31 +
 package.json                    |    3 +
 tests/docs/__init__.py          |    0
 tests/docs/test_architecture.py |  120 ++++
 8 files changed, 1444 insertions(+), 158 deletions(-)

Fusion-Task-Id: FN-019
2026-05-14 10:56:20 +02:00
Fusion 7ff7acb717 feat(FN-002): React Frontend App Skeleton (+1 more)
Commits merged:
- feat(FN-002): add .local-bin to .gitignore for local pnpm bootstrap
- feat(FN-002): complete Step 2 — React Frontend App Skeleton

Files changed:
.gitignore | 1 +
 1 file changed, 1 insertion(+)

Fusion-Task-Id: FN-002
2026-05-14 09:52:11 +02:00
Fusion f33a563003 feat(FN-003): add tool manifest registry with FastAPI CRUD and built-in manifests
- Add ToolManifest Pydantic models with validators for ports, mounts, health checks, and traefik config

- Implement in-memory ToolRegistry with YAML loading and built-in manifest scanning

- Add FastAPI CRUD routes for listing, retrieving, and creating tool manifests

- Include built-in manifests for runfusion and code-server

- Harden web Dockerfile with unprivileged nginx and port 8080

- Add tool manifest specification documentation and architecture updates

Fusion-Task-Id: FN-003
2026-05-14 09:15:22 +02:00
Fusion 477abad4c9 fix(FN-011): gracefully skip DB tests when PostgreSQL is unavailable
- Wrap DB engine setup in try/except in conftest.py

- Call pytest.skip with clear message when PostgreSQL is unavailable

- Dispose engine before skipping to avoid connection leaks

Fusion-Task-Id: FN-011
2026-05-14 08:45:52 +02:00
Fusion 2c52b1634f docs(FN-011): complete Step 8 — update architecture and development docs
Fusion-Task-Id: FN-011
Fusion-Task-Lineage: 4a9aca6f-9d91-43aa-8d2a-d59657c1541a
2026-05-14 08:37:11 +02:00
Fusion 31b363edb0 test(FN-011): complete Step 7 — tests for provider, credentials, and operations
Fusion-Task-Id: FN-011
Fusion-Task-Lineage: 4a9aca6f-9d91-43aa-8d2a-d59657c1541a
2026-05-14 08:37:11 +02:00
Fusion a9ccbcb3fb feat(FN-011): add repository_connection alembic migration
Fusion-Task-Id: FN-011
Fusion-Task-Lineage: 4a9aca6f-9d91-43aa-8d2a-d59657c1541a
2026-05-14 08:37:11 +02:00
Fusion ea30f28edb fix(FN-011): catch CalledProcessError in get_status, fix deletion logic, add explicit encoding
Fusion-Task-Id: FN-011
Fusion-Task-Lineage: 4a9aca6f-9d91-43aa-8d2a-d59657c1541a
2026-05-14 08:37:11 +02:00
Fusion 3532bec00d feat(FN-011): complete Step 5 — Git operations interface and LocalGitOperations
Fusion-Task-Id: FN-011
Fusion-Task-Lineage: 4a9aca6f-9d91-43aa-8d2a-d59657c1541a
2026-05-14 08:37:11 +02:00
Fusion 4214b48c37 fix(FN-011): wrap validate_connection in try/except, flush before return, remove unused import, document unique constraint deferral
Fusion-Task-Id: FN-011
Fusion-Task-Lineage: 4a9aca6f-9d91-43aa-8d2a-d59657c1541a
2026-05-14 08:37:11 +02:00
44 changed files with 2977 additions and 175 deletions
+1
View File
@@ -6,6 +6,7 @@ TOOL_DOMAIN=tools.localhost
# API / Web URLs # API / Web URLs
API_URL=http://localhost:8000 API_URL=http://localhost:8000
WEB_URL=http://localhost:5173 WEB_URL=http://localhost:5173
CORS_ORIGINS=http://localhost:5173
# Database (local development) # Database (local development)
POSTGRES_USER=postgres POSTGRES_USER=postgres
+1
View File
@@ -54,3 +54,4 @@ docker-volumes/
.cache/ .cache/
.temp/ .temp/
tmp/ tmp/
.local-bin/
+1
View File
@@ -0,0 +1 @@
ignore-build-scripts=false
+2 -1
View File
@@ -4,10 +4,11 @@ Hosted workspace and tool-orchestration platform where authenticated users creat
## Current Status ## Current Status
This repository is an initial scaffold (FN-002). It provides: This repository provides:
- React + Vite + TypeScript frontend (`apps/web`) - React + Vite + TypeScript frontend (`apps/web`)
- FastAPI + Python backend (`apps/api`) - FastAPI + Python backend (`apps/api`)
- Manifest-driven tool registry with built-in RunFusion and code-server definitions
- Root monorepo tooling (pnpm workspace, Makefile) - Root monorepo tooling (pnpm workspace, Makefile)
- Docker Compose local development stack - Docker Compose local development stack
- Deployment skeleton for Portainer + Traefik - Deployment skeleton for Portainer + Traefik
+4
View File
@@ -5,10 +5,14 @@ WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
COPY app/ ./app/ COPY app/ ./app/
COPY pyproject.toml ./ COPY pyproject.toml ./
RUN pip install --no-cache-dir -e "." RUN pip install --no-cache-dir -e "."
USER appuser
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
@@ -0,0 +1,51 @@
"""add repository_connection
Revision ID: 42a78fd41e23
Revises: 6cfa61694d0a
Create Date: 2026-05-14 08:19:37.912177
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '42a78fd41e23'
down_revision: Union[str, Sequence[str], None] = '6cfa61694d0a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('repository_connection',
sa.Column('project_id', sa.Uuid(), nullable=False),
sa.Column('repository_id', sa.Uuid(), nullable=True),
sa.Column('provider_kind', sa.String(length=50), nullable=False),
sa.Column('credential_id', sa.Uuid(), nullable=True),
sa.Column('connection_status', sa.String(length=50), nullable=False),
sa.Column('default_branch', sa.String(length=100), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_repository_connection_credential_id'), 'repository_connection', ['credential_id'], unique=False)
op.create_index(op.f('ix_repository_connection_project_id'), 'repository_connection', ['project_id'], unique=False)
op.create_index(op.f('ix_repository_connection_repository_id'), 'repository_connection', ['repository_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_repository_connection_repository_id'), table_name='repository_connection')
op.drop_index(op.f('ix_repository_connection_project_id'), table_name='repository_connection')
op.drop_index(op.f('ix_repository_connection_credential_id'), table_name='repository_connection')
op.drop_table('repository_connection')
# ### end Alembic commands ###
+3
View File
@@ -20,6 +20,9 @@ class Settings(BaseSettings):
# Database # Database
database_url: str = "postgresql://postgres:postgres@localhost:5432/headquarter" database_url: str = "postgresql://postgres:postgres@localhost:5432/headquarter"
# CORS
cors_origins: str = "http://localhost:5173"
# Deployment # Deployment
root_domain: str = "localhost" root_domain: str = "localhost"
tool_subdomain_pattern: str = "{tool}-{project}-{user}.tools.{root_domain}" tool_subdomain_pattern: str = "{tool}-{project}-{user}.tools.{root_domain}"
+3
View File
@@ -2,6 +2,7 @@
from app.git.connection import ConnectionManager, RepositoryConnectionData from app.git.connection import ConnectionManager, RepositoryConnectionData
from app.git.credentials import AccessTokenCredential, CredentialStorage, GitCredential from app.git.credentials import AccessTokenCredential, CredentialStorage, GitCredential
from app.git.operations import GitOperations, LocalGitOperations
from app.git.provider import GitProvider from app.git.provider import GitProvider
from app.git.ssh_key import SshKeyLifecycle, SshKeyPair from app.git.ssh_key import SshKeyLifecycle, SshKeyPair
from app.git.types import ConnectionStatus, CredentialKind, ProviderKind, SshKeyStatus from app.git.types import ConnectionStatus, CredentialKind, ProviderKind, SshKeyStatus
@@ -13,7 +14,9 @@ __all__ = [
"CredentialKind", "CredentialKind",
"CredentialStorage", "CredentialStorage",
"GitCredential", "GitCredential",
"GitOperations",
"GitProvider", "GitProvider",
"LocalGitOperations",
"ProviderKind", "ProviderKind",
"RepositoryConnectionData", "RepositoryConnectionData",
"SshKeyLifecycle", "SshKeyLifecycle",
+10 -3
View File
@@ -3,7 +3,6 @@
import uuid import uuid
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.git.credentials import CredentialStorage, GitCredential from app.git.credentials import CredentialStorage, GitCredential
@@ -50,7 +49,15 @@ class ConnectionManager:
session.add(row) session.add(row)
await session.flush() await session.flush()
status = self.provider.validate_connection(git_url, str(credential_id)) try:
status = self.provider.validate_connection(
git_url, str(credential_id)
)
except Exception:
row.connection_status = str(ConnectionStatus.error)
await session.flush()
raise RuntimeError("Connection validation failed")
if status == ConnectionStatus.connected: if status == ConnectionStatus.connected:
row.connection_status = str(ConnectionStatus.connected) row.connection_status = str(ConnectionStatus.connected)
else: else:
@@ -58,7 +65,7 @@ class ConnectionManager:
await session.flush() await session.flush()
raise RuntimeError("Connection validation failed") raise RuntimeError("Connection validation failed")
await session.refresh(row) await session.flush()
return _map_row(row) return _map_row(row)
async def disconnect( async def disconnect(
+3 -7
View File
@@ -7,7 +7,7 @@ Security rules:
import abc import abc
import uuid import uuid
from datetime import datetime, timezone from datetime import UTC, datetime
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -26,12 +26,8 @@ class GitCredential(BaseModel):
id: uuid.UUID = Field(default_factory=uuid.uuid4) id: uuid.UUID = Field(default_factory=uuid.uuid4)
kind: CredentialKind kind: CredentialKind
encrypted_payload: str = Field(repr=False) encrypted_payload: str = Field(repr=False)
created_at: datetime = Field( created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
default_factory=lambda: datetime.now(timezone.utc) updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
)
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc)
)
class AccessTokenCredential(GitCredential): class AccessTokenCredential(GitCredential):
+107
View File
@@ -0,0 +1,107 @@
"""Local Git subprocess interface."""
import abc
import subprocess
from pathlib import Path
from typing import Any
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
def clone(self, git_url: str, dest: Path, credential_id: str) -> None:
"""Clone *git_url* into *dest* using *credential_id*."""
@abc.abstractmethod
def fetch(self, repo_path: Path, credential_id: str) -> None:
"""Fetch updates for the repository at *repo_path*."""
@abc.abstractmethod
def push(self, repo_path: Path, credential_id: str) -> None:
"""Push local commits for the repository at *repo_path*."""
@abc.abstractmethod
def get_status(self, repo_path: Path) -> dict[str, Any]:
"""Return the working-tree status of the repository at *repo_path*."""
class LocalGitOperations(GitOperations):
"""Git operations backed by the local ``git`` CLI."""
def clone(self, git_url: str, dest: Path, credential_id: str) -> None:
raise NotImplementedError(
"Credential-aware subprocess invocation will be implemented in a follow-up task"
)
def fetch(self, repo_path: Path, credential_id: str) -> None:
raise NotImplementedError(
"Credential-aware subprocess invocation will be implemented in a follow-up task"
)
def push(self, repo_path: Path, credential_id: str) -> None:
raise NotImplementedError(
"Credential-aware subprocess invocation will be implemented in a follow-up task"
)
def get_status(self, repo_path: Path) -> dict[str, Any]:
if not repo_path.exists() or not (repo_path / ".git").is_dir():
raise RuntimeError("Not a git repository")
try:
branch_result = subprocess.run(
["git", "-C", str(repo_path), "branch", "--show-current"],
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
branch = branch_result.stdout.strip()
status_result = subprocess.run(
["git", "-C", str(repo_path), "status", "--porcelain"],
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
except subprocess.CalledProcessError as exc:
raise RuntimeError("Git command failed") from exc
untracked: list[str] = []
modified: list[str] = []
staged: list[str] = []
deleted: list[str] = []
for line in status_result.stdout.splitlines():
if len(line) < 3:
continue
index_status = line[0]
worktree_status = line[1]
filename = line[3:]
if index_status == "?" and worktree_status == "?":
untracked.append(filename)
elif index_status in ("M", "A"):
staged.append(filename)
if index_status == "D" or worktree_status == "D":
deleted.append(filename)
if worktree_status == "M":
modified.append(filename)
clean = not (untracked or modified or staged or deleted)
return {
"branch": branch,
"clean": clean,
"untracked": untracked,
"modified": modified,
"staged": staged,
"deleted": deleted,
}
+5 -9
View File
@@ -8,7 +8,7 @@ Security rules:
import base64 import base64
import uuid import uuid
from datetime import datetime, timezone from datetime import UTC, datetime
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -31,12 +31,8 @@ class SshKeyPair(BaseModel):
public_key: str public_key: str
encrypted_private_key: str = Field(repr=False) encrypted_private_key: str = Field(repr=False)
status: SshKeyStatus = SshKeyStatus.generated status: SshKeyStatus = SshKeyStatus.generated
created_at: datetime = Field( created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
default_factory=lambda: datetime.now(timezone.utc) updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
)
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc)
)
revoked_at: datetime | None = None revoked_at: datetime | None = None
@@ -83,7 +79,7 @@ class SshKeyLifecycle:
Sets ``revoked_at`` when transitioning to :attr:`SshKeyStatus.revoked`. Sets ``revoked_at`` when transitioning to :attr:`SshKeyStatus.revoked`.
""" """
key.status = new_status key.status = new_status
key.updated_at = datetime.now(timezone.utc) key.updated_at = datetime.now(UTC)
if new_status == SshKeyStatus.revoked: if new_status == SshKeyStatus.revoked:
key.revoked_at = datetime.now(timezone.utc) key.revoked_at = datetime.now(UTC)
return key return key
+5
View File
@@ -9,10 +9,13 @@ from sqlalchemy import text
from app.config import settings from app.config import settings
from app.db import AsyncSessionLocal, engine from app.db import AsyncSessionLocal, engine
from app.routers import routers from app.routers import routers
from app.tools.registry import registry
from app.tools.router import router as tools_router
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
registry.load_builtin_manifests()
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
try: try:
await session.execute(text("SELECT 1")) await session.execute(text("SELECT 1"))
@@ -41,6 +44,8 @@ app.add_middleware(
for router in routers: for router in routers:
app.include_router(router, prefix=settings.api_v1_prefix) app.include_router(router, prefix=settings.api_v1_prefix)
app.include_router(tools_router, prefix=settings.api_v1_prefix)
@app.get("/health") @app.get("/health")
async def health() -> JSONResponse: async def health() -> JSONResponse:
@@ -14,6 +14,9 @@ if TYPE_CHECKING:
class RepositoryConnection(Base, UUIDMixin, TimestampMixin): class RepositoryConnection(Base, UUIDMixin, TimestampMixin):
__tablename__ = "repository_connection" __tablename__ = "repository_connection"
# NOTE: A partial unique index on (project_id, repository_id, provider_kind)
# when repository_id IS NOT NULL is deferred for MVP. Duplicate connections
# are acceptable until explicit disambiguation is required.
project_id: Mapped[uuid.UUID] = mapped_column( project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("project.id"), index=True ForeignKey("project.id"), index=True
View File
@@ -0,0 +1,44 @@
id: code-server
name: code-server
description: VS Code in the browser.
version: "1.0.0"
image: codercom/code-server:latest
runtime_working_dir: /workspace
ports:
- container_port: 8080
protocol: tcp
name: http
primary: true
workspace_mounts:
- type: volume
source_pattern: "{project_repo}"
target: /workspace
read_only: false
config_mounts:
- type: volume
source_pattern: "{user_config}/code-server"
target: /home/coder/.config/code-server
read_only: false
env: {}
secrets:
- name: code-server-password
env_var: PASSWORD
required: false
health_check:
type: http
path: /healthz
port: 8080
interval_seconds: 10
timeout_seconds: 5
retries: 3
start_period_seconds: 5
resource_limits:
cpus: 2.0
memory_mb: 4096
memory_swap_mb: -1
traefik:
enabled: true
subdomain_prefix: code
port: 8080
middlewares: []
strip_prefix: false
@@ -0,0 +1,46 @@
id: runfusion
name: RunFusion
description: Executable Node.js environment for running and developing applications.
version: "1.0.0"
image: node:22-slim
runtime_working_dir: /workspace
ports:
- container_port: 8080
protocol: tcp
name: http
primary: true
workspace_mounts:
- type: volume
source_pattern: "{project_repo}"
target: /workspace
read_only: false
config_mounts:
- type: volume
source_pattern: "{user_config}/runfusion"
target: /home/node/.config
read_only: false
env:
NODE_ENV: development
health_check:
type: http
path: /
port: 8080
interval_seconds: 10
timeout_seconds: 5
retries: 3
start_period_seconds: 10
resource_limits:
cpus: 2.0
memory_mb: 2048
memory_swap_mb: -1
executable:
node_version: "22"
package_manager: npm
bootstrap_commands: []
install_commands: []
traefik:
enabled: true
subdomain_prefix: runfusion
port: 8080
middlewares: []
strip_prefix: false
+109
View File
@@ -0,0 +1,109 @@
"""Pydantic v2 models for the Headquarter tool manifest schema."""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator
class PortConfig(BaseModel):
container_port: int = Field(..., ge=1, le=65535)
protocol: Literal["tcp", "udp"] = "tcp"
name: str | None = None
primary: bool = False
class MountConfig(BaseModel):
type: Literal["volume", "bind"] = "volume"
source_pattern: str
target: str
read_only: bool = False
@field_validator("target")
@classmethod
def _target_must_be_absolute(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("mount target must be an absolute path")
return v
class SecretRef(BaseModel):
name: str
env_var: str
required: bool = True
class HealthCheckConfig(BaseModel):
type: Literal["http", "tcp", "command"] = "http"
path: str | None = None
command: list[str] | None = None
port: int | None = None
interval_seconds: int = Field(default=10, ge=1)
timeout_seconds: int = Field(default=5, ge=1)
retries: int = Field(default=3, ge=1)
start_period_seconds: int = Field(default=5, ge=0)
@model_validator(mode="after")
def _check_required_fields(self) -> HealthCheckConfig:
if self.type == "http" and not self.path:
raise ValueError('path is required when health_check.type is "http"')
if self.type == "command" and not self.command:
raise ValueError('command is required when health_check.type is "command"')
return self
class ResourceLimits(BaseModel):
cpus: float | None = Field(None, ge=0.01)
memory_mb: int | None = Field(None, ge=16)
memory_swap_mb: int | None = Field(None, ge=-1)
class ExecutableConfig(BaseModel):
node_version: str | None = None
npm_version: str | None = None
package_manager: Literal["npm", "pnpm", "yarn", "bun"] = "npm"
bootstrap_commands: list[str] = []
install_commands: list[str] = []
class TraefikConfig(BaseModel):
enabled: bool = True
subdomain_prefix: str | None = None
port: int | None = None
middlewares: list[str] = []
strip_prefix: bool = False
entrypoint: str | None = None
cert_resolver: str | None = None
class ToolManifest(BaseModel):
id: str = Field(..., pattern=r"^[a-z0-9\-]+$")
name: str
description: str = ""
version: str = "1.0.0"
image: str
runtime_command: list[str] | None = None
runtime_entrypoint: list[str] | None = None
runtime_user: str | None = None
runtime_working_dir: str | None = None
ports: list[PortConfig] = []
workspace_mounts: list[MountConfig] = []
config_mounts: list[MountConfig] = []
env: dict[str, str] = {}
secrets: list[SecretRef] = []
health_check: HealthCheckConfig | None = None
resource_limits: ResourceLimits | None = None
executable: ExecutableConfig | None = None
traefik: TraefikConfig | None = None
@model_validator(mode="after")
def _check_traefik_primary_port(self) -> ToolManifest:
traefik = self.traefik
if traefik is not None and traefik.enabled:
has_primary = any(port.primary for port in self.ports)
if not has_primary:
raise ValueError(
"at least one port must have primary=True when traefik.enabled is True"
)
return self
+52
View File
@@ -0,0 +1,52 @@
"""In-memory tool manifest registry with YAML file loading."""
from __future__ import annotations
from pathlib import Path
import yaml
from app.tools.models import ToolManifest
class ToolRegistry:
"""In-memory registry for tool manifests."""
def __init__(self) -> None:
self._manifests: dict[str, ToolManifest] = {}
def load_builtin_manifests(self) -> None:
"""Scan the built-in manifests directory and register all *.yml files."""
manifests_dir = Path(__file__).parent / "manifests"
if not manifests_dir.exists():
return
for file_path in sorted(manifests_dir.glob("*.yml")):
self.load_file(file_path)
def load_file(self, path: Path) -> ToolManifest:
"""Load a single YAML manifest file, validate it, and register it."""
data = yaml.safe_load(path.read_text(encoding="utf-8"))
manifest = ToolManifest.model_validate(data)
self.register(manifest)
return manifest
def register(self, manifest: ToolManifest) -> None:
"""Store a manifest in the registry (idempotent upsert)."""
self._manifests[manifest.id] = manifest
def get(self, tool_id: str) -> ToolManifest | None:
"""Retrieve a manifest by tool id, or None if not found."""
return self._manifests.get(tool_id)
def list(self) -> list[ToolManifest]:
"""Return all registered manifests."""
return list(self._manifests.values())
def remove(self, tool_id: str) -> ToolManifest | None:
"""Remove a manifest by tool id and return it, or None if not found."""
return self._manifests.pop(tool_id, None)
# Module-level singleton — callers must explicitly bootstrap via
# registry.load_builtin_manifests() (typically in a FastAPI lifespan).
registry = ToolRegistry()
+37
View File
@@ -0,0 +1,37 @@
"""FastAPI routes for the tool manifest registry."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, status
from app.tools.models import ToolManifest
from app.tools.registry import registry
router = APIRouter(prefix="/tools", tags=["tools"])
@router.get("", response_model=list[ToolManifest])
def list_tools() -> list[ToolManifest]:
return registry.list()
@router.get("/{tool_id}", response_model=ToolManifest)
def get_tool(tool_id: str) -> ToolManifest:
manifest = registry.get(tool_id)
if manifest is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tool '{tool_id}' not found",
)
return manifest
@router.post("", response_model=ToolManifest, status_code=status.HTTP_201_CREATED)
def create_tool(manifest: ToolManifest) -> ToolManifest:
if registry.get(manifest.id) is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Tool '{manifest.id}' already exists",
)
registry.register(manifest)
return manifest
+5
View File
@@ -13,6 +13,7 @@ dependencies = [
"pyjwt>=2.8.0", "pyjwt>=2.8.0",
"cryptography>=44.0.0", "cryptography>=44.0.0",
"httpx>=0.28.0", "httpx>=0.28.0",
"pyyaml>=6.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -46,6 +47,10 @@ strict = true
warn_return_any = true warn_return_any = true
warn_unused_configs = true warn_unused_configs = true
exclude = ["alembic/versions"] exclude = ["alembic/versions"]
plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]]
module = "yaml"
ignore_missing_imports = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
+6 -2
View File
@@ -34,8 +34,12 @@ def event_loop() -> Generator[Any, None, None]:
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
async def db_engine() -> AsyncGenerator[AsyncEngine, None]: async def db_engine() -> AsyncGenerator[AsyncEngine, None]:
engine = create_async_engine(TEST_DATABASE_URL, echo=False, poolclass=NullPool) engine = create_async_engine(TEST_DATABASE_URL, echo=False, poolclass=NullPool)
async with engine.begin() as conn: try:
await conn.run_sync(Base.metadata.create_all) async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
except Exception as exc:
await engine.dispose()
pytest.skip(f"PostgreSQL unavailable for tests: {exc}")
yield engine yield engine
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.drop_all)
+77
View File
@@ -0,0 +1,77 @@
"""Tests for credential models and storage interface."""
import uuid
import pytest
from app.git.credentials import AccessTokenCredential, CredentialStorage, GitCredential
from app.git.types import CredentialKind
class MinimalCredentialStorage(CredentialStorage):
"""Concrete subclass for testing."""
def create(self, credential: GitCredential) -> uuid.UUID:
return credential.id
def get(self, credential_id: uuid.UUID) -> GitCredential | None:
return None
def delete(self, credential_id: uuid.UUID) -> None:
return None
def test_git_credential_can_be_instantiated() -> None:
cred = GitCredential(
kind=CredentialKind.ssh_key,
encrypted_payload="encrypted-data",
)
assert cred.kind == CredentialKind.ssh_key
assert cred.encrypted_payload == "encrypted-data"
assert isinstance(cred.id, uuid.UUID)
def test_access_token_credential_can_be_instantiated() -> None:
cred = AccessTokenCredential(encrypted_payload="encrypted-data")
assert cred.kind == CredentialKind.access_token
assert cred.encrypted_payload == "encrypted-data"
def test_credential_storage_cannot_be_instantiated_directly() -> None:
with pytest.raises(TypeError):
CredentialStorage() # type: ignore[abstract]
def test_no_plaintext_secret_fields() -> None:
fields = set(GitCredential.model_fields.keys())
assert "token" not in fields
assert "private_key" not in fields
def test_extra_forbidden() -> None:
with pytest.raises(ValueError):
GitCredential(
kind=CredentialKind.access_token,
encrypted_payload="encrypted-data",
secret_plaintext="should-fail", # type: ignore[call-arg]
)
def test_minimal_credential_storage_implements_all_methods() -> None:
storage = MinimalCredentialStorage()
cred = GitCredential(
kind=CredentialKind.access_token,
encrypted_payload="encrypted-data",
)
assert storage.create(cred) == cred.id
assert storage.get(cred.id) is None
storage.delete(cred.id)
def test_encrypted_payload_not_in_repr() -> None:
cred = GitCredential(
kind=CredentialKind.ssh_key,
encrypted_payload="secret-value",
)
repr_str = repr(cred)
assert "secret-value" not in repr_str
+148
View File
@@ -0,0 +1,148 @@
"""Tests for local Git operations."""
import subprocess
import tempfile
from pathlib import Path
from typing import Any
import pytest
from app.git.operations import LocalGitOperations
@pytest.fixture
def local_git() -> LocalGitOperations:
return LocalGitOperations()
@pytest.fixture
def temp_repo() -> Any:
with tempfile.TemporaryDirectory() as tmpdir:
repo_path = Path(tmpdir) / "repo"
repo_path.mkdir()
subprocess.run(
["git", "init", "--initial-branch=main"],
cwd=repo_path,
capture_output=True,
text=True,
check=True,
)
subprocess.run(
["git", "config", "user.email", "test@example.com"],
cwd=repo_path,
capture_output=True,
text=True,
check=True,
)
subprocess.run(
["git", "config", "user.name", "Test User"],
cwd=repo_path,
capture_output=True,
text=True,
check=True,
)
yield repo_path
def test_clone_raises_not_implemented_error(local_git: LocalGitOperations) -> None:
with pytest.raises(NotImplementedError):
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:
with pytest.raises(NotImplementedError):
local_git.fetch(temp_repo, "cred-id")
def test_push_raises_not_implemented_error(local_git: LocalGitOperations, temp_repo: Path) -> None:
with pytest.raises(NotImplementedError):
local_git.push(temp_repo, "cred-id")
def test_get_status_on_non_git_directory_raises(local_git: LocalGitOperations) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
with pytest.raises(RuntimeError, match="Not a git repository"):
local_git.get_status(Path(tmpdir))
def test_get_status_clean_repo(local_git: LocalGitOperations, temp_repo: Path) -> None:
status = local_git.get_status(temp_repo)
assert status["branch"] == "main"
assert status["clean"] is True
assert status["untracked"] == []
assert status["modified"] == []
assert status["staged"] == []
assert status["deleted"] == []
def test_get_status_untracked_file(local_git: LocalGitOperations, temp_repo: Path) -> None:
(temp_repo / "newfile.txt").write_text("hello")
status = local_git.get_status(temp_repo)
assert "newfile.txt" in status["untracked"]
assert status["clean"] is False
def test_get_status_staged_file(local_git: LocalGitOperations, temp_repo: Path) -> None:
file_path = temp_repo / "newfile.txt"
file_path.write_text("hello")
subprocess.run(
["git", "add", "newfile.txt"],
cwd=temp_repo,
capture_output=True,
text=True,
check=True,
)
status = local_git.get_status(temp_repo)
assert "newfile.txt" in status["staged"]
assert status["clean"] is False
def test_get_status_modified_file(local_git: LocalGitOperations, temp_repo: Path) -> None:
file_path = temp_repo / "newfile.txt"
file_path.write_text("hello")
subprocess.run(
["git", "add", "newfile.txt"],
cwd=temp_repo,
capture_output=True,
text=True,
check=True,
)
file_path.write_text("world")
status = local_git.get_status(temp_repo)
assert "newfile.txt" in status["modified"]
assert status["clean"] is False
def test_get_status_deleted_file(local_git: LocalGitOperations, temp_repo: Path) -> None:
file_path = temp_repo / "newfile.txt"
file_path.write_text("hello")
subprocess.run(
["git", "add", "newfile.txt"],
cwd=temp_repo,
capture_output=True,
text=True,
check=True,
)
subprocess.run(
["git", "commit", "-m", "add file"],
cwd=temp_repo,
capture_output=True,
text=True,
check=True,
)
file_path.unlink()
status = local_git.get_status(temp_repo)
assert "newfile.txt" in status["deleted"]
assert status["clean"] is False
def test_get_status_branch_name(local_git: LocalGitOperations, temp_repo: Path) -> None:
subprocess.run(
["git", "checkout", "-b", "feature-branch"],
cwd=temp_repo,
capture_output=True,
text=True,
check=True,
)
status = local_git.get_status(temp_repo)
assert status["branch"] == "feature-branch"
+66
View File
@@ -0,0 +1,66 @@
"""Tests for Git provider abstraction and types."""
from typing import Any
import pytest
from app.git.provider import GitProvider
from app.git.types import ConnectionStatus, CredentialKind, ProviderKind, SshKeyStatus
class MinimalGitProvider(GitProvider):
"""Concrete subclass for testing."""
def get_kind(self) -> ProviderKind:
return ProviderKind.generic
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 "key-id"
def delete_deploy_key(self, git_url: str, deploy_key_id: str) -> None:
return None
def get_default_branch(self, git_url: str, credential_id: str) -> str:
return "main"
def test_git_provider_cannot_be_instantiated_directly() -> None:
with pytest.raises(TypeError):
GitProvider() # type: ignore[abstract]
def test_minimal_git_provider_implements_all_methods() -> None:
provider = MinimalGitProvider()
assert provider.get_kind() == ProviderKind.generic
status = provider.validate_connection("https://example.com/repo.git", "cred-id")
assert status == ConnectionStatus.connected
assert provider.list_repositories("cred-id") == []
assert provider.create_deploy_key("https://example.com/repo.git", "ssh-rsa AAAA") == "key-id"
provider.delete_deploy_key("https://example.com/repo.git", "key-id")
assert provider.get_default_branch("https://example.com/repo.git", "cred-id") == "main"
@pytest.mark.parametrize("member", ["github", "gitlab", "gitea", "forgejo", "generic"])
def test_provider_kind_membership(member: str) -> None:
assert member in ProviderKind
@pytest.mark.parametrize("member", ["ssh_key", "access_token"])
def test_credential_kind_membership(member: str) -> None:
assert member in CredentialKind
@pytest.mark.parametrize("member", ["pending", "connected", "disconnected", "error"])
def test_connection_status_membership(member: str) -> None:
assert member in ConnectionStatus
@pytest.mark.parametrize("member", ["generated", "registered", "rotating", "revoked"])
def test_ssh_key_status_membership(member: str) -> None:
assert member in SshKeyStatus
+243
View File
@@ -0,0 +1,243 @@
"""Tests for the tool manifest Pydantic models."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.tools.models import (
ExecutableConfig,
HealthCheckConfig,
MountConfig,
PortConfig,
ResourceLimits,
SecretRef,
ToolManifest,
TraefikConfig,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _minimal_manifest(**overrides: object) -> ToolManifest:
defaults: dict[str, object] = {
"id": "test-tool",
"name": "Test Tool",
"image": "test:latest",
"ports": [PortConfig(container_port=8080, primary=True)],
"traefik": TraefikConfig(enabled=False),
}
defaults.update(overrides)
return ToolManifest.model_validate(defaults)
# ---------------------------------------------------------------------------
# Valid construction
# ---------------------------------------------------------------------------
def test_valid_runfusion_shape() -> None:
manifest = ToolManifest(
id="runfusion",
name="RunFusion",
description="Executable Node.js environment.",
image="node:22-slim",
runtime_working_dir="/workspace",
ports=[PortConfig(container_port=8080, name="http", primary=True)],
workspace_mounts=[
MountConfig(source_pattern="{project_repo}", target="/workspace")
],
config_mounts=[
MountConfig(
source_pattern="{user_config}/runfusion", target="/home/node/.config"
)
],
env={"NODE_ENV": "development"},
health_check=HealthCheckConfig(
type="http", path="/", port=8080, start_period_seconds=10
),
resource_limits=ResourceLimits(cpus=2.0, memory_mb=2048),
executable=ExecutableConfig(node_version="22", package_manager="npm"),
traefik=TraefikConfig(
enabled=True, subdomain_prefix="runfusion", port=8080
),
)
assert manifest.id == "runfusion"
assert manifest.ports[0].primary is True
assert manifest.traefik is not None
assert manifest.traefik.enabled is True
def test_valid_code_server_shape() -> None:
manifest = ToolManifest(
id="code-server",
name="code-server",
description="VS Code in the browser.",
image="codercom/code-server:latest",
runtime_working_dir="/workspace",
ports=[PortConfig(container_port=8080, name="http", primary=True)],
workspace_mounts=[
MountConfig(source_pattern="{project_repo}", target="/workspace")
],
config_mounts=[
MountConfig(
source_pattern="{user_config}/code-server",
target="/home/coder/.config/code-server",
)
],
health_check=HealthCheckConfig(type="http", path="/healthz", port=8080),
resource_limits=ResourceLimits(cpus=2.0, memory_mb=4096),
traefik=TraefikConfig(enabled=True, subdomain_prefix="code", port=8080),
secrets=[
SecretRef(name="code-server-password", env_var="PASSWORD", required=False)
],
)
assert manifest.id == "code-server"
assert manifest.secrets[0].env_var == "PASSWORD"
# ---------------------------------------------------------------------------
# Invalid id values
# ---------------------------------------------------------------------------
def test_invalid_id_uppercase() -> None:
with pytest.raises(ValidationError) as exc_info:
_minimal_manifest(id="RunFusion")
assert "id" in str(exc_info.value)
def test_invalid_id_spaces() -> None:
with pytest.raises(ValidationError) as exc_info:
_minimal_manifest(id="run fusion")
assert "id" in str(exc_info.value)
def test_invalid_id_empty_string() -> None:
with pytest.raises(ValidationError) as exc_info:
_minimal_manifest(id="")
assert "id" in str(exc_info.value)
# ---------------------------------------------------------------------------
# Port validation
# ---------------------------------------------------------------------------
def test_invalid_container_port_zero() -> None:
with pytest.raises(ValidationError) as exc_info:
PortConfig(container_port=0)
assert "container_port" in str(exc_info.value)
def test_invalid_container_port_too_high() -> None:
with pytest.raises(ValidationError) as exc_info:
PortConfig(container_port=70000)
assert "container_port" in str(exc_info.value)
# ---------------------------------------------------------------------------
# Mount target validation
# ---------------------------------------------------------------------------
def test_mount_target_not_absolute() -> None:
with pytest.raises(ValidationError) as exc_info:
MountConfig(source_pattern="{project_repo}", target="workspace")
assert "absolute" in str(exc_info.value).lower()
# ---------------------------------------------------------------------------
# HealthCheck validation
# ---------------------------------------------------------------------------
def test_health_check_http_missing_path() -> None:
with pytest.raises(ValidationError) as exc_info:
HealthCheckConfig(type="http")
assert "path" in str(exc_info.value)
def test_health_check_command_missing_command() -> None:
with pytest.raises(ValidationError) as exc_info:
HealthCheckConfig(type="command")
assert "command" in str(exc_info.value)
def test_health_check_tcp_allows_missing_path() -> None:
hc = HealthCheckConfig(type="tcp")
assert hc.type == "tcp"
# ---------------------------------------------------------------------------
# Traefik + primary port validation
# ---------------------------------------------------------------------------
def test_missing_primary_port_when_traefik_enabled() -> None:
with pytest.raises(ValidationError) as exc_info:
ToolManifest(
id="bad-tool",
name="Bad Tool",
image="test:latest",
ports=[PortConfig(container_port=8080, primary=False)],
traefik=TraefikConfig(enabled=True),
)
assert "primary" in str(exc_info.value).lower()
def test_traefik_disabled_allows_no_primary_port() -> None:
manifest = ToolManifest(
id="no-route",
name="No Route",
image="test:latest",
ports=[PortConfig(container_port=8080, primary=False)],
traefik=TraefikConfig(enabled=False),
)
assert manifest.traefik is not None
assert manifest.traefik.enabled is False
def test_no_traefik_allows_no_primary_port() -> None:
manifest = ToolManifest(
id="no-route",
name="No Route",
image="test:latest",
ports=[PortConfig(container_port=8080, primary=False)],
)
assert manifest.traefik is None
# ---------------------------------------------------------------------------
# Resource limits validation
# ---------------------------------------------------------------------------
def test_resource_limits_cpus_too_low() -> None:
with pytest.raises(ValidationError) as exc_info:
ResourceLimits(cpus=0.001)
assert "cpus" in str(exc_info.value)
def test_resource_limits_memory_mb_too_low() -> None:
with pytest.raises(ValidationError) as exc_info:
ResourceLimits(memory_mb=8)
assert "memory_mb" in str(exc_info.value)
def test_resource_limits_memory_swap_negative_one_ok() -> None:
rl = ResourceLimits(memory_swap_mb=-1)
assert rl.memory_swap_mb == -1
# ---------------------------------------------------------------------------
# Serialization round-trip
# ---------------------------------------------------------------------------
def test_serialization_roundtrip() -> None:
original = _minimal_manifest(
id="roundtrip",
name="Roundtrip Tool",
ports=[PortConfig(container_port=3000, name="http", primary=True)],
traefik=TraefikConfig(enabled=True, subdomain_prefix="rt"),
)
dumped = original.model_dump(mode="json")
restored = ToolManifest.model_validate(dumped)
assert restored.id == original.id
assert restored.ports[0].container_port == original.ports[0].container_port
assert restored.traefik is not None
assert restored.traefik.subdomain_prefix == "rt"
+159
View File
@@ -0,0 +1,159 @@
"""Tests for the in-memory tool manifest registry."""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import ValidationError
from app.tools.models import PortConfig, ToolManifest, TraefikConfig
from app.tools.registry import ToolRegistry
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def registry() -> ToolRegistry:
return ToolRegistry()
@pytest.fixture
def sample_manifest() -> ToolManifest:
return ToolManifest(
id="test-tool",
name="Test Tool",
image="test:latest",
ports=[PortConfig(container_port=8080, primary=True)],
traefik=TraefikConfig(enabled=False),
)
# ---------------------------------------------------------------------------
# Register / get round-trip
# ---------------------------------------------------------------------------
def test_register_and_get(registry: ToolRegistry, sample_manifest: ToolManifest) -> None:
registry.register(sample_manifest)
retrieved = registry.get("test-tool")
assert retrieved is not None
assert retrieved.id == "test-tool"
def test_get_missing_returns_none(registry: ToolRegistry) -> None:
assert registry.get("missing") is None
# ---------------------------------------------------------------------------
# List
# ---------------------------------------------------------------------------
def test_list_returns_all(registry: ToolRegistry) -> None:
m1 = ToolManifest(
id="tool-a",
name="Tool A",
image="a:latest",
ports=[PortConfig(container_port=8080, primary=True)],
traefik=TraefikConfig(enabled=False),
)
m2 = ToolManifest(
id="tool-b",
name="Tool B",
image="b:latest",
ports=[PortConfig(container_port=3000, primary=True)],
traefik=TraefikConfig(enabled=False),
)
registry.register(m1)
registry.register(m2)
assert len(registry.list()) == 2
ids = {m.id for m in registry.list()}
assert ids == {"tool-a", "tool-b"}
# ---------------------------------------------------------------------------
# Overwrite behavior
# ---------------------------------------------------------------------------
def test_register_overwrites_existing(
registry: ToolRegistry,
sample_manifest: ToolManifest,
) -> None:
registry.register(sample_manifest)
updated = ToolManifest(
id="test-tool",
name="Updated Tool",
image="updated:latest",
ports=[PortConfig(container_port=8080, primary=True)],
traefik=TraefikConfig(enabled=False),
)
registry.register(updated)
retrieved = registry.get("test-tool")
assert retrieved is not None
assert retrieved.name == "Updated Tool"
# ---------------------------------------------------------------------------
# Remove
# ---------------------------------------------------------------------------
def test_remove_returns_manifest(registry: ToolRegistry, sample_manifest: ToolManifest) -> None:
registry.register(sample_manifest)
removed = registry.remove("test-tool")
assert removed is not None
assert removed.id == "test-tool"
assert registry.get("test-tool") is None
def test_remove_missing_returns_none(registry: ToolRegistry) -> None:
assert registry.remove("missing") is None
# ---------------------------------------------------------------------------
# Load file
# ---------------------------------------------------------------------------
def test_load_valid_yaml_file(registry: ToolRegistry, tmp_path: Path) -> None:
yaml_path = tmp_path / "my-tool.yml"
yaml_path.write_text(
"""
id: my-tool
name: My Tool
image: my-tool:latest
ports:
- container_port: 8080
primary: true
traefik:
enabled: false
""",
encoding="utf-8",
)
manifest = registry.load_file(yaml_path)
assert manifest.id == "my-tool"
assert manifest.name == "My Tool"
assert manifest.ports[0].container_port == 8080
def test_load_invalid_yaml_raises(registry: ToolRegistry, tmp_path: Path) -> None:
yaml_path = tmp_path / "bad-tool.yml"
yaml_path.write_text(
"""
id: BAD ID
name: Bad Tool
image: bad:latest
""",
encoding="utf-8",
)
with pytest.raises(ValidationError):
registry.load_file(yaml_path)
# ---------------------------------------------------------------------------
# Load builtin manifests
# ---------------------------------------------------------------------------
def test_load_builtin_manifests(registry: ToolRegistry) -> None:
registry.load_builtin_manifests()
# Built-in manifests from Step 4 may not exist yet in isolation,
# but the method should not raise regardless of directory contents.
assert isinstance(registry.list(), list)
+93
View File
@@ -0,0 +1,93 @@
"""Tests for the FastAPI tool manifest router."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.tools.registry import registry
client = TestClient(app)
@pytest.fixture(autouse=True)
def _reset_registry() -> None:
registry._manifests.clear()
registry.load_builtin_manifests()
# ---------------------------------------------------------------------------
# GET /api/v1/tools
# ---------------------------------------------------------------------------
def test_list_tools_includes_builtins() -> None:
response = client.get("/api/v1/tools")
assert response.status_code == 200
data = response.json()
ids = {item["id"] for item in data}
assert "runfusion" in ids
assert "code-server" in ids
# ---------------------------------------------------------------------------
# GET /api/v1/tools/{tool_id}
# ---------------------------------------------------------------------------
def test_get_tool_runfusion() -> None:
response = client.get("/api/v1/tools/runfusion")
assert response.status_code == 200
data = response.json()
assert data["id"] == "runfusion"
assert data["name"] == "RunFusion"
def test_get_tool_not_found() -> None:
response = client.get("/api/v1/tools/nonexistent")
assert response.status_code == 404
# ---------------------------------------------------------------------------
# POST /api/v1/tools
# ---------------------------------------------------------------------------
def test_create_tool_success() -> None:
payload = {
"id": "new-tool",
"name": "New Tool",
"image": "new-tool:latest",
"ports": [{"container_port": 3000, "primary": True}],
"traefik": {"enabled": False},
}
response = client.post("/api/v1/tools", json=payload)
assert response.status_code == 201
data = response.json()
assert data["id"] == "new-tool"
assert data["name"] == "New Tool"
def test_create_tool_duplicate() -> None:
payload = {
"id": "runfusion",
"name": "Duplicate",
"image": "dup:latest",
"ports": [{"container_port": 3000, "primary": True}],
"traefik": {"enabled": False},
}
response = client.post("/api/v1/tools", json=payload)
assert response.status_code == 409
def test_create_tool_invalid_id() -> None:
payload = {
"id": "Bad ID",
"name": "Bad Tool",
"image": "bad:latest",
"ports": [{"container_port": 3000, "primary": True}],
"traefik": {"enabled": False},
}
response = client.post("/api/v1/tools", json=payload)
assert response.status_code == 422
+1
View File
@@ -0,0 +1 @@
ignore-build-scripts=false
+2 -2
View File
@@ -9,9 +9,9 @@ RUN npm install -g pnpm@11.1.1 \
COPY . . COPY . .
RUN pnpm build RUN pnpm build
FROM nginx:alpine FROM nginxinc/nginx-unprivileged:alpine
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 8080
+3 -1
View File
@@ -1,5 +1,5 @@
server { server {
listen 80; listen 8080;
server_name localhost; server_name localhost;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
@@ -14,6 +14,8 @@ server {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; proxy_set_header Connection 'upgrade';
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
} }
} }
+3
View File
@@ -3,6 +3,9 @@
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"type": "module", "type": "module",
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
},
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
+2 -2
View File
@@ -27,7 +27,7 @@ services:
context: ./apps/web context: ./apps/web
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- "5173:80" - "5173:8080"
depends_on: depends_on:
- api - api
restart: unless-stopped restart: unless-stopped
@@ -41,7 +41,7 @@ services:
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
ports: ports:
- "5432:5432" - "127.0.0.1:5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-headquarter}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-headquarter}"]
interval: 5s interval: 5s
+8 -3
View File
@@ -4,12 +4,17 @@ This directory contains architecture, development, and deployment documentation
## Index ## Index
- [Architecture](architecture.md) — System architecture, stack decisions, and MVP phases *(FN-001)* - [Architecture](architecture.md) — Canonical system architecture, domain model, provider contracts, and security boundaries *(FN-019)*
- [MVP Scope](mvp-scope.md) — MVP boundaries, user journeys, milestones, and dependency order *(FN-019)*
- [Project Brief](project-brief.md) — High-level product context: what, why, who, and confirmed stack *(FN-019)*
- [Development](development.md) — Local setup, prerequisites, and day-to-day commands *(FN-002)* - [Development](development.md) — Local setup, prerequisites, and day-to-day commands *(FN-002)*
- [Deployment](deployment.md) — Deployment assumptions, Portainer/Traefik skeleton, and follow-up scope *(FN-002)* - [Deployment](deployment.md) — Deployment assumptions, Portainer/Traefik skeleton, and operator guide *(FN-002)*
## Reference
- [Conversation Handoff](conversation-handoff.md) — Key architectural decisions, assumptions, and open loops from planning *(FN-019)*
## Quick Links ## Quick Links
- [Root README](../README.md) - [Root README](../README.md)
- [Deploy Skeleton](../deploy/README.md) - [Deploy Skeleton](../deploy/README.md)
- [Project Brief](project-brief.md) — Original product brief and confirmed stack *(FN-001)*
+1036 -145
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
# Conversation Handoff: FN-019
## Task Context
This document captures the key architectural decisions, assumptions, and open loops produced during **FN-019 — Architecture and MVP Specification for Headquarter**.
## Key Decisions Made
### 1. Auth Callback Flow (Section 12.2)
- **Decision:** Backend-handled callback is the recommended MVP pattern.
- **Rationale:** The frontend receives the Authentik redirect at a backend endpoint (`GET /api/v1/auth/callback`), the backend exchanges the code for tokens, sets an httpOnly cookie, and returns an HTTP 302 redirect to the frontend dashboard. This avoids exposing the client secret to the frontend and avoids the `fetch()` + redirect ambiguity.
### 2. GitProvider Split (Section 5.1)
- **Decision:** Two complementary abstractions: `GitProvider` (remote API operations) and `GitOperations` (local Git CLI).
- **Rationale:** This separation was already implemented in FN-011. The architecture doc preserves and formalizes it.
### 3. AccessProvider Protocol (Section 9.2)
- **Decision:** Added an explicit `AccessProvider` ABC with `RoutingConfig` Pydantic model.
- **Rationale:** The original architecture doc mentioned `AccessProvider` as an extension point but never defined method signatures. The enhanced doc makes it as concrete as `GitProvider` and `RuntimeProvider`.
### 4. Subdomain Pattern (Section 10.1)
- **Decision:** Default pattern is `{tool}-{project}-{user}.{tool_domain}`.
- **Rationale:** Aligns with existing `config.py` (`tool_subdomain_pattern`) and downstream FN-006 label generator. The relationship between `ROOT_DOMAIN` and `TOOL_DOMAIN` is now explicitly documented.
### 5. Dev Bypass Security Model (Section 12.7)
- **Decision:** `AUTH_DEV_BYPASS` is an environment variable, not a query parameter.
- **Rationale:** The backend must reject the bypass when `settings.debug` is `False`, even if the env var is set. This prevents accidental production exposure.
### 6. Credential Storage Abstraction (Section 4.10, 5.3, 6.3)
- **Decision:** Credentials are stored as encrypted `secret` rows with `scope_type='repository'`.
- **Rationale:** The `CredentialStorage` ABC (from FN-011) is the interface, but the canonical storage is the `secret` table. The architecture doc now cross-references correctly.
## Assumptions
1. **Single-tenant MVP:** The platform runs as a single deployment with no hard multi-tenant isolation.
2. **Portainer-managed stacks:** Production deployment assumes an existing Portainer instance.
3. **Existing Traefik:** The reverse proxy is already running and attached to an external Docker network named `traefik`.
4. **Authentik pre-configured:** The OIDC application is created in Authentik before deployment.
5. **PostgreSQL 17+:** The database schema uses features compatible with PostgreSQL 17.
## Open Loops for Future Tasks
1. **User slug derivation (architecture.md Section 18, Open Question 2):** Should the user slug for subdomain generation be derived from `display_name`, `email` local-part, or a new `slug` column? Decision needed before FN-006/FN-010 implementation.
2. **Admin role in MVP (architecture.md Section 18, Open Question 1):** Do we need a basic admin role for global config management? Decision needed before FN-009 API implementation.
3. **Auto-deploy-key registration (architecture.md Section 18, Open Question 4):** Should the platform auto-register deploy keys via provider APIs, or is manual copy-paste acceptable for MVP? Decision needed before FN-011 UI work.
4. **Container image trust (architecture.md Section 18, Open Question 5):** Should the platform restrict tool images to an allow-list? Decision needed before FN-010/FN-008 spawn implementation.
5. **Subdomain truncation strategy (architecture.md Section 10.1):** DNS labels have a 63-byte limit. A deterministic truncation/hashing strategy for long project or user names is needed before FN-006 label generation is finalized.
6. **Global config write permissions (architecture.md Section 13.7):** The architecture doc documents two possible MVP behaviors (allow all authenticated users, or reject with 403). A stakeholder must choose before FN-009 router implementation.
## Files Modified / Created
- `docs/architecture.md` — Rewritten with 18 required sections
- `docs/mvp-scope.md` — New file
- `docs/project-brief.md` — New file
- `docs/conversation-handoff.md` — New file (this document)
- `docs/README.md` — Updated index
- `tests/docs/test_architecture.py` — New automated validation suite
- `tests/docs/__init__.py` — New empty init
## Downstream Dependencies
- **FN-004:** Backend Foundation — depends on the PostgreSQL domain model in Section 4
- **FN-005:** Frontend Foundation — depends on component boundaries in Section 3.1
- **FN-006:** Deployment Config — depends on Traefik routing model in Section 10
- **FN-008:** RunFusion POC — depends on spawn lifecycle in Section 8 and Docker runtime in Section 9
- **FN-009:** Config & Secrets — depends on storage layout in Section 11 and security in Section 13
- **FN-010:** code-server Spawn — depends on tool manifest in Section 7 and Docker runtime in Section 9
- **FN-011:** Git Provider — depends on Git provider abstraction in Section 5
+20
View File
@@ -175,3 +175,23 @@ pnpm build
- **Frontend**: React functional components, TypeScript strict mode, ESLint + Ruff-like rules. - **Frontend**: React functional components, TypeScript strict mode, ESLint + Ruff-like rules.
- **Backend**: FastAPI, Pydantic settings, pytest, ruff, mypy. - **Backend**: FastAPI, Pydantic settings, pytest, ruff, mypy.
- **Commits**: Conventional commits with task ID prefix, e.g. `feat(FN-002): description`. - **Commits**: Conventional commits with task ID prefix, e.g. `feat(FN-002): description`.
## Git Abstraction
The `app/git/` package in the backend provides provider-independent Git
orchestration. It is split into two layers so that remote provider API logic
and local CLI operations evolve independently:
| Module | Responsibility |
|--------|--------------|
| `types` | Enumerations (`ProviderKind`, `CredentialKind`, `ConnectionStatus`, `SshKeyStatus`) |
| `provider` | Abstract `GitProvider` — remote operations (`validate_connection`, `list_repositories`, `create_deploy_key`, …) |
| `credentials` | `GitCredential` / `AccessTokenCredential` models and `CredentialStorage` ABC |
| `ssh_key` | `SshKeyPair` model and `SshKeyLifecycle` (Ed25519 generation via `cryptography`) |
| `connection` | `RepositoryConnection` ORM mapping and `ConnectionManager` orchestration |
| `operations` | Abstract `GitOperations` and concrete `LocalGitOperations` (subprocess-based `get_status`) |
Security rules for the package:
- 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`.
- Real encryption of the payload is deferred to FN-009; the current placeholder is base64-only.
+184
View File
@@ -0,0 +1,184 @@
# Headquarter MVP Scope
> Canonical definition of what is in, out, and deferred for the Minimum Viable Product.
> This document is the scope boundary for all downstream implementation tasks.
---
## 1. Product Vision
Headquarter is a hosted workspace and tool-orchestration platform for developers who want self-hosted control over their development environments. It gives authenticated users a single dashboard to create Git-backed projects, connect repositories from any provider, and spawn containerized tools—starting with RunFusion and code-server—on demand, each accessible via its own HTTPS subdomain. Headquarter is for individual developers and small teams who outgrow cloud IDEs but do not want to build their own orchestration layer from scratch.
---
## 2. MVP User Journeys
An MVP user can complete the following end-to-end flows without assistance:
### 2.1 Sign Up / Log In via Authentik
- User clicks "Sign In" and is redirected to the organization's Authentik instance.
- After OIDC authentication, the user is redirected back to the Headquarter dashboard.
- A `User` row is created automatically on first login.
### 2.2 Create a Project
- User clicks "New Project" and provides a name and optional description.
- The backend generates a URL-friendly `slug` from the name.
- The project appears in the user's project list.
### 2.3 Connect a Git Repository
- User selects a project and chooses "Connect Repository."
- User provides the Git clone URL and selects the provider type (GitHub, GitLab, Gitea, Forgejo, or generic).
- The backend creates a `Repository` row and a `RepositoryConnection` row.
### 2.4 Generate Per-Repository SSH Credentials
- User clicks "Generate SSH Key" for a repository connection.
- The backend generates an Ed25519 key pair, encrypts the private key, and stores it.
- The public key is displayed to the user for manual registration at the provider, or registered automatically via the provider adapter when available.
### 2.5 Spawn a Tool Instance
- User navigates to "Tools" and selects a tool (RunFusion or code-server).
- User chooses a project and optional config overrides.
- The backend generates a Docker Compose service definition, Traefik labels, and starts the container.
- The tool instance receives workspace mounts, config mounts, and secret injection.
### 2.6 Access the Running Tool via Subdomain
- After the tool instance reaches `running` or `healthy` status, the user sees a link.
- The link follows the subdomain pattern: `https://{tool}-{project}-{user}.{tool_domain}`.
- Traefik routes the subdomain to the container's exposed port over HTTPS.
### 2.7 Stop and Restart a Tool Instance
- User clicks "Stop" on a running tool instance.
- The backend calls Docker to stop the container and updates the status to `stopped`.
- User can click "Start" to re-provision the container with the same configuration.
### 2.8 Configure Tool Settings
- User navigates to "Settings" for a project or their user profile.
- User can create, update, or delete config values at project or user scope.
- Config values are stored as JSON and mounted into tool containers at runtime.
### 2.9 Store and Inject Secrets
- User navigates to "Secrets" for a project.
- User creates a secret by providing a key name and value.
- The backend encrypts the value with Fernet before storage.
- At spawn time, the backend decrypts the secret and injects it as an environment variable or mounted file.
---
## 3. In-Scope Features
- **Authentik OIDC authentication** with automatic user provisioning
- **Project management** (CRUD, ownership-based)
- **Repository connections** with provider-agnostic Git URL storage
- **Per-repository SSH key generation** (Ed25519) with encrypted private-key storage
- **Tool registry** with manifest-driven definitions for RunFusion and code-server
- **Tool instance spawning** via Docker Compose with Traefik subdomain routing
- **Tool instance lifecycle** (start, stop, health checks, status tracking)
- **Persistent config storage** at global, user, project, and tool-instance scopes
- **Encrypted secret storage** at user, project, and tool-instance scopes
- **Traefik label generation** for dynamic subdomain routing
- **Local development stack** via Docker Compose (API, web, PostgreSQL)
- **Deployment skeleton** for Portainer-managed production stacks
---
## 4. Out-of-Scope Features (Non-Goals)
The following are explicitly excluded from MVP to prevent scope creep:
- **Multi-user teams / shared projects** — Schema leaves room for `ProjectMember`, but no UI or API in MVP
- **Real-time collaboration** — No shared cursors, simultaneous editing, or presence
- **Advanced CI/CD pipelines** — No build orchestration, test runners, or deployment stages
- **Kubernetes runtime** — Docker Compose only; Kubernetes adapter is a future extension point
- **Non-Docker runtimes** — No Podman, LXC, or VM runtimes in MVP
- **Automatic Git provider webhooks** — No push-triggered actions or webhook receivers
- **Built-in GitHub/GitLab UI integrations** — No issue trackers, PR viewers, or code review UI
- **Backup and disaster recovery automation** — Rely on host-level volume backups
- **High availability / replicas** — Single-instance deployment only
- **Rate limiting** — No API or Traefik rate limits in MVP
- **Audit logging** — No immutable audit trail of user actions
- **Automatic credential rotation** — Manual rotation only
- **Container image vulnerability scanning** — No image trust enforcement
---
## 5. MVP Milestones / Slices
Slices are ordered by dependency. Each slice corresponds to a task on the Fusion board.
| Slice | Task ID | Title | Deliverable |
|-------|---------|-------|-------------|
| 1 | **FN-002** | Monorepo Scaffold | Root tooling, frontend/backend skeletons, Docker Compose, deployment skeleton |
| 2 | **FN-019** | Architecture & Specification | Enhanced `docs/architecture.md`, `docs/mvp-scope.md`, doc validation tests |
| 3 | **FN-004** | Backend Foundation | Domain models, Alembic migrations, auth boundaries, secret encryption, API routers |
| 4 | **FN-005** | Frontend Foundation | Auth shell, navigation, placeholder pages, API client, config layer |
| 5 | **FN-003** | Tool Registry | Manifest schema, in-memory registry, built-in RunFusion/code-server manifests, FastAPI routes |
| 6 | **FN-006** | Deployment Config | Traefik label generator, production Compose stacks, Portainer stack definition |
| 7 | **FN-011** | Git Provider Model | Provider abstraction, SSH key lifecycle, credential models, repository connection |
| 8 | **FN-009** | Config & Secrets | Encrypted storage, runtime injection, frontend config/secrets UI |
| 9 | **FN-010** | code-server Spawn | code-server manifest, spawn flow, runtime integration, auth layer |
| 10 | **FN-008** | RunFusion POC | Executable environment, Node/npm runtime, health reporting |
**Dependency notes:**
- FN-004 and FN-005 can proceed in parallel once FN-019 is complete.
- FN-003 depends on FN-004 (backend models exist).
- FN-006 depends on FN-002 (scaffold exists) and benefits from FN-003 (manifest routing fields).
- FN-011 depends on FN-004 (models and test infrastructure).
- FN-009 depends on FN-004 and FN-005.
- FN-010 depends on FN-003, FN-006, and FN-009.
- FN-008 depends on FN-003, FN-006, and FN-009.
---
## 6. Dependency Order for Downstream Implementation
```
FN-002 (Scaffold)
├──> FN-019 (Architecture) ──> FN-004 (Backend)
│ │
│ ├──> FN-003 (Tool Registry)
│ │ │
│ │ ├──> FN-010 (code-server Spawn)
│ │ └──> FN-008 (RunFusion POC)
│ │
│ ├──> FN-011 (Git Provider)
│ │
│ └──> FN-009 (Config/Secrets)
│ │
│ └──> FN-010, FN-008 (runtime injection)
└──> FN-005 (Frontend) ───────> FN-009 (Config/Secrets UI)
FN-006 (Deployment) runs in parallel with FN-004/FN-005
after FN-002 is complete.
```
**Critical path:** FN-002 → FN-019 → FN-004 → FN-003 → FN-010/FN-008
---
## 7. Definition of MVP Done
MVP is complete and shippable when **all** of the following are true:
1. A user can sign up, create a project, connect a Git repository, and spawn code-server from a single dashboard.
2. Spawned tools are accessible via HTTPS subdomains routed through Traefik.
3. Secrets and configs are encrypted at rest and injected correctly at runtime.
4. SSH keys are generated per repository and used for Git operations inside containers.
5. All backend tests pass (`pytest`), all frontend tests pass (`vitest`), and all lint/typecheck gates pass.
6. The production stack (`docker-compose.prod.yml`) deploys cleanly via Portainer.
7. Documentation (`architecture.md`, `mvp-scope.md`, `deployment.md`, `development.md`) is accurate and consistent with the implementation.
8. No incomplete placeholders or task markers remain in committed code or documentation.
---
## 8. Open Questions
The following scope decisions are pending stakeholder input. Implementers should not choose defaults for these without explicit approval:
1. **Admin role in MVP:** Do we need a basic admin role for global config management, or can all authenticated users write global config in MVP?
2. **User slug derivation:** Should the user slug for subdomain generation be derived from `display_name`, `email` local-part, or a new dedicated `slug` column?
3. **Provider adapter coverage:** Which Git providers get concrete adapters in MVP? GitHub and GitLab are assumed; Gitea and Forgejo may be deferred.
4. **Auto-deploy-key registration:** Should the platform attempt to register deploy keys automatically via provider APIs, or is manual copy-paste acceptable for MVP?
5. **Container image trust:** Should the platform restrict tool images to an allow-list in production, or is any image reference acceptable in MVP?
6. **Billing or resource quotas:** Is any form of usage limiting or project quota needed in MVP, or is it strictly single-user-unlimited?
+31
View File
@@ -0,0 +1,31 @@
# Project Brief: Headquarter
## What
Headquarter is a hosted workspace and tool-orchestration platform. Authenticated users create Git-backed projects and launch containerized development tools—starting with RunFusion and code-server—each exposed via its own HTTPS subdomain.
## Why
Cloud IDEs and CI dashboards are convenient but lock users into proprietary platforms. Headquarter gives developers the same convenience with full control over their runtime environments, source code, and routing.
## Who
- Individual developers who want self-hosted workspaces
- Small teams that outgrow cloud IDEs but do not want to build orchestration from scratch
- Operators who prefer Docker Compose and Traefik over Kubernetes for simple deployments
## Confirmed Stack
- **Frontend:** React + Vite + TypeScript
- **Backend:** FastAPI + SQLAlchemy 2.0 + Pydantic v2
- **Database:** PostgreSQL 17
- **Auth:** Authentik OIDC
- **Runtime:** Docker Compose (Portainer-managed)
- **Routing:** Traefik subdomain-based
## Canonical Documentation
- [Architecture](architecture.md) — System design, domain model, and provider contracts
- [MVP Scope](mvp-scope.md) — In-scope features, non-goals, milestones, and dependency order
- [Development](development.md) — Local setup and day-to-day commands
- [Deployment](deployment.md) — Production deployment assumptions and operator guide
+209
View File
@@ -0,0 +1,209 @@
# Tool Manifest Specification
> Canonical schema reference for Headquarter's manifest-driven tool registry.
> Version: 1.0.0 — aligned with FN-003.
## Overview
Headquarter is a manifest-driven platform: every containerized tool (RunFusion, code-server, and future tools) is declared by a YAML manifest. The orchestration backend reads these manifests to generate Docker Compose services, Traefik routing labels, volume mounts, and resource constraints.
**Design goal:** Adding a new standard container tool requires only a YAML manifest—no backend code changes.
## Manifest File Format
Manifests are YAML files with a single top-level mapping. They are validated on load by Pydantic v2 models.
### Built-in location
Built-in manifests live in `apps/api/app/tools/manifests/*.yml` and are loaded automatically on API startup.
### Minimal valid manifest
```yaml
id: my-tool
name: My Tool
image: my-org/my-tool:latest
ports:
- container_port: 8080
primary: true
traefik:
enabled: false
```
## Field Reference
### `ToolManifest` (top level)
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `id` | `string` | yes | — | Lowercase slug with hyphens only (`^[a-z0-9\-]+$`). Used as the registry key. |
| `name` | `string` | yes | — | Human-readable tool name. |
| `description` | `string` | no | `""` | Short description of the tool. |
| `version` | `string` | no | `"1.0.0"` | Manifest version (semver-ish). |
| `image` | `string` | yes | — | Docker image reference. |
| `runtime_command` | `string[] \| null` | no | `null` | Override the container default command. |
| `runtime_entrypoint` | `string[] \| null` | no | `null` | Override the container entrypoint. |
| `runtime_user` | `string \| null` | no | `null` | User to run as inside the container. |
| `runtime_working_dir` | `string \| null` | no | `null` | Working directory inside the container. |
| `ports` | `PortConfig[]` | no | `[]` | Exposed ports. |
| `workspace_mounts` | `MountConfig[]` | no | `[]` | Workspace volume mounts (project-scoped). |
| `config_mounts` | `MountConfig[]` | no | `[]` | Config volume mounts (user or tool-scoped). |
| `env` | `dict<string, string>` | no | `{}` | Static environment variables. |
| `secrets` | `SecretRef[]` | no | `[]` | Secrets injected as environment variables. |
| `health_check` | `HealthCheckConfig \| null` | no | `null` | Health check definition. |
| `resource_limits` | `ResourceLimits \| null` | no | `null` | CPU and memory constraints. |
| `executable` | `ExecutableConfig \| null` | no | `null` | Node.js runtime metadata for executable environments. |
| `traefik` | `TraefikConfig \| null` | no | `null` | Traefik routing configuration. |
### `PortConfig`
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `container_port` | `int` | yes | — | Port inside the container. Range: 165535. |
| `protocol` | `"tcp" \| "udp"` | no | `"tcp"` | Transport protocol. |
| `name` | `string \| null` | no | `null` | Logical name, e.g. `"http"`, `"websocket"`. |
| `primary` | `bool` | no | `false` | The port used for default routing and health checks. |
### `MountConfig`
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `type` | `"volume" \| "bind"` | no | `"volume"` | Mount type. |
| `source_pattern` | `string` | yes | — | Template pattern resolved at spawn time, e.g. `"{project_repo}"`. |
| `target` | `string` | yes | — | Absolute path inside the container. Must start with `/`. |
| `read_only` | `bool` | no | `false` | Mount read-only. |
### `SecretRef`
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `name` | `string` | yes | — | Secret identifier in the secret store. |
| `env_var` | `string` | yes | — | Name of the environment variable injected into the container. |
| `required` | `bool` | no | `true` | Whether the tool fails to start if the secret is missing. |
### `HealthCheckConfig`
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `type` | `"http" \| "tcp" \| "command"` | no | `"http"` | Health check mechanism. |
| `path` | `string \| null` | no | `null` | HTTP path. Required when `type == "http"`. |
| `command` | `string[] \| null` | no | `null` | Command to execute. Required when `type == "command"`. |
| `port` | `int \| null` | no | `null` | Override port; defaults to the primary port if unset. |
| `interval_seconds` | `int` | no | `10` | Check interval. ≥ 1. |
| `timeout_seconds` | `int` | no | `5` | Check timeout. ≥ 1. |
| `retries` | `int` | no | `3` | Retries before marking unhealthy. ≥ 1. |
| `start_period_seconds` | `int` | no | `5` | Grace period before checks count. ≥ 0. |
### `ResourceLimits`
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `cpus` | `float \| null` | no | `null` | CPU limit. If set, ≥ 0.01. |
| `memory_mb` | `int \| null` | no | `null` | Memory limit in MiB. If set, ≥ 16. |
| `memory_swap_mb` | `int \| null` | no | `null` | Swap limit in MiB. `-1` disables swap limit. |
### `ExecutableConfig`
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `node_version` | `string \| null` | no | `null` | Expected Node.js version, e.g. `"22"`, `"lts"`. |
| `npm_version` | `string \| null` | no | `null` | Expected npm version. |
| `package_manager` | `"npm" \| "pnpm" \| "yarn" \| "bun"` | no | `"npm"` | Preferred package manager. |
| `bootstrap_commands` | `string[]` | no | `[]` | One-time setup commands run on first start. |
| `install_commands` | `string[]` | no | `[]` | Commands run before the main command. |
### `TraefikConfig`
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `enabled` | `bool` | no | `true` | Whether Traefik routing is generated for this tool. |
| `subdomain_prefix` | `string \| null` | no | `null` | Subdomain prefix. Defaults to the tool `id`. |
| `port` | `int \| null` | no | `null` | Container port to route traffic to. |
| `middlewares` | `string[]` | no | `[]` | Traefik middleware names to apply. |
| `strip_prefix` | `bool` | no | `false` | Strip path prefix before forwarding. |
| `entrypoint` | `string \| null` | no | `null` | Override the environment default Traefik entrypoint. |
| `cert_resolver` | `string \| null` | no | `null` | Override the environment default cert resolver. |
## Validation Rules
1. `id` must match `^[a-z0-9\-]+$` (lowercase, digits, hyphens only).
2. `MountConfig.target` must be an absolute path (`starts with "/"`).
3. When `health_check.type == "http"`, `path` must be set and non-empty.
4. When `health_check.type == "command"`, `command` must be set and non-empty.
5. `container_port` must be between 1 and 65535.
6. `cpus`, if set, must be ≥ 0.01.
7. `memory_mb`, if set, must be ≥ 16.
8. If `traefik.enabled` is `true`, at least one port must have `primary: true`.
## Example: RunFusion Manifest
```yaml
id: runfusion
name: RunFusion
description: Executable Node.js environment for running and developing applications.
version: "1.0.0"
image: node:22-slim
runtime_working_dir: /workspace
ports:
- container_port: 8080
protocol: tcp
name: http
primary: true
workspace_mounts:
- type: volume
source_pattern: "{project_repo}"
target: /workspace
read_only: false
config_mounts:
- type: volume
source_pattern: "{user_config}/runfusion"
target: /home/node/.config
read_only: false
env:
NODE_ENV: development
health_check:
type: http
path: /
port: 8080
interval_seconds: 10
timeout_seconds: 5
retries: 3
start_period_seconds: 10
resource_limits:
cpus: 2.0
memory_mb: 2048
memory_swap_mb: -1
executable:
node_version: "22"
package_manager: npm
bootstrap_commands: []
install_commands: []
traefik:
enabled: true
subdomain_prefix: runfusion
port: 8080
middlewares: []
strip_prefix: false
```
## Extension Guide: Adding a New Tool
To add a new standard container tool:
1. Create a new YAML file in `apps/api/app/tools/manifests/{tool-id}.yml`.
2. Populate all required fields (`id`, `name`, `image`, `ports`).
3. Set `traefik.enabled: true` and mark one port as `primary: true` if the tool needs HTTP routing.
4. Declare `workspace_mounts` and `config_mounts` as needed.
5. Restart the API (or call `registry.load_builtin_manifests()`).
No backend code changes are required for standard containers that expose an HTTP port and need volume mounts.
## Registry API
The in-memory registry exposes FastAPI routes under `/api/v1/tools`:
- `GET /api/v1/tools` — list all registered manifests.
- `GET /api/v1/tools/{id}` — retrieve a single manifest.
- `POST /api/v1/tools` — register a new manifest (returns 409 if `id` already exists).
Built-in manifests are loaded automatically on application startup via the FastAPI lifespan context manager.
+6
View File
@@ -12,9 +12,15 @@
"compose:up": "docker compose up --build -d", "compose:up": "docker compose up --build -d",
"compose:down": "docker compose down" "compose:down": "docker compose down"
}, },
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
},
"packageManager": "pnpm@11.1.1", "packageManager": "pnpm@11.1.1",
"engines": { "engines": {
"node": ">=20", "node": ">=20",
"pnpm": ">=9" "pnpm": ">=9"
},
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
} }
} }
View File
+120
View File
@@ -0,0 +1,120 @@
"""Automated validation suite for documentation structural and content requirements."""
import re
from pathlib import Path
import pytest
DOCS_DIR = Path(__file__).resolve().parent.parent.parent / "docs"
ROOT_DIR = Path(__file__).resolve().parent.parent.parent
class TestArchitectureDoc:
def test_file_exists_and_is_non_empty(self) -> None:
path = DOCS_DIR / "architecture.md"
assert path.exists(), "docs/architecture.md must exist"
assert path.stat().st_size > 0, "docs/architecture.md must be non-empty"
def test_contains_required_section_headers(self) -> None:
path = DOCS_DIR / "architecture.md"
content = path.read_text()
required_sections = [
"## 1. Overview",
"## 2. System Context",
"## 3. Component Boundaries",
"## 4. PostgreSQL Domain Model",
"## 5. Git Provider Abstraction",
"## 6. Repository Credential Model",
"## 7. Tool Manifest Model",
"## 8. Tool Spawn Lifecycle",
"## 9. Docker Runtime Abstraction",
"## 10. Traefik Subdomain Routing Model",
"## 11. Storage Layout",
"## 12. Authentik OIDC Auth Flow",
"## 13. Security Considerations",
"## 14. Extension Points",
"## 15. Environment Assumptions",
"## 16. Technology Boundaries",
"## 17. Acceptance Criteria for Architecture Compliance",
"## 18. Deferred Decisions",
]
missing = [s for s in required_sections if s not in content]
assert not missing, f"Missing required sections: {missing}"
def test_contains_required_references(self) -> None:
path = DOCS_DIR / "architecture.md"
content = path.read_text()
required_refs = [
"GitProvider",
"RuntimeProvider",
"AccessProvider",
"ToolManifest",
"Authentik",
"Traefik",
"Portainer",
"PostgreSQL",
]
missing = [r for r in required_refs if r not in content]
assert not missing, f"Missing required references: {missing}"
class TestMvpScopeDoc:
def test_file_exists_and_is_non_empty(self) -> None:
path = DOCS_DIR / "mvp-scope.md"
assert path.exists(), "docs/mvp-scope.md must exist"
assert path.stat().st_size > 0, "docs/mvp-scope.md must be non-empty"
def test_contains_required_sections(self) -> None:
path = DOCS_DIR / "mvp-scope.md"
content = path.read_text()
required_sections = [
"## 1. Product Vision",
"## 2. MVP User Journeys",
"## 3. In-Scope",
"## 4. Out-of-Scope",
"## 5. MVP Milestones",
"## 6. Dependency Order",
"## 7. Definition of MVP Done",
"## 8. Open Questions",
]
missing = [s for s in required_sections if s not in content]
assert not missing, f"Missing required sections: {missing}"
class TestAllDocs:
def test_no_todo_or_fixme_in_docs(self) -> None:
markdown_files = list(DOCS_DIR.rglob("*.md"))
assert markdown_files, "No markdown files found in docs/"
violations = []
for path in markdown_files:
content = path.read_text()
if re.search(r"\bTODO\b", content, re.IGNORECASE):
violations.append(f"{path.name} contains TODO")
if re.search(r"\bFIXME\b", content, re.IGNORECASE):
violations.append(f"{path.name} contains FIXME")
assert not violations, f"Documentation contains placeholders: {violations}"
def test_internal_links_point_to_existing_files(self) -> None:
markdown_files = list(DOCS_DIR.rglob("*.md"))
link_pattern = re.compile(r"\]\(([^)]+)\)")
violations = []
for path in markdown_files:
content = path.read_text()
for match in link_pattern.finditer(content):
link = match.group(1)
# Skip external URLs and anchors
if link.startswith("http") or link.startswith("#") or link.startswith("mailto:"):
continue
# Resolve relative to the docs directory or repo root
if link.startswith("docs/"):
target = ROOT_DIR / link
elif link.startswith("../"):
target = path.parent / link
elif link.startswith("./"):
target = path.parent / link
else:
# Assume relative to docs dir for bare paths like "architecture.md"
target = DOCS_DIR / link
if not target.exists():
violations.append(f"{path.name}: broken link to '{link}'")
assert not violations, f"Broken internal links found: {violations}"