diff --git a/.env.example b/.env.example index a757916..9de3464 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ TOOL_DOMAIN=tools.localhost # API / Web URLs API_URL=http://localhost:8000 WEB_URL=http://localhost:5173 +CORS_ORIGINS=http://localhost:5173 # Database (local development) POSTGRES_USER=postgres diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..38e4d30 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +ignore-build-scripts=false diff --git a/README.md b/README.md index f486034..dc3e768 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,11 @@ Hosted workspace and tool-orchestration platform where authenticated users creat ## Current Status -This repository is an initial scaffold (FN-002). It provides: +This repository provides: - React + Vite + TypeScript frontend (`apps/web`) - FastAPI + Python backend (`apps/api`) +- Manifest-driven tool registry with built-in RunFusion and code-server definitions - Root monorepo tooling (pnpm workspace, Makefile) - Docker Compose local development stack - Deployment skeleton for Portainer + Traefik diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 5caba5e..e8d6d76 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -5,10 +5,14 @@ WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 +RUN groupadd -r appgroup && useradd -r -g appgroup appuser + COPY app/ ./app/ COPY pyproject.toml ./ RUN pip install --no-cache-dir -e "." +USER appuser + EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/apps/api/app/config.py b/apps/api/app/config.py index 153ad7a..6545814 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -20,6 +20,9 @@ class Settings(BaseSettings): # Database database_url: str = "postgresql://postgres:postgres@localhost:5432/headquarter" + # CORS + cors_origins: str = "http://localhost:5173" + # Deployment root_domain: str = "localhost" tool_subdomain_pattern: str = "{tool}-{project}-{user}.tools.{root_domain}" diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 5efa600..fa2d6dd 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -9,10 +9,13 @@ from sqlalchemy import text from app.config import settings from app.db import AsyncSessionLocal, engine from app.routers import routers +from app.tools.registry import registry +from app.tools.router import router as tools_router @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + registry.load_builtin_manifests() async with AsyncSessionLocal() as session: try: await session.execute(text("SELECT 1")) @@ -41,6 +44,8 @@ app.add_middleware( for router in routers: app.include_router(router, prefix=settings.api_v1_prefix) +app.include_router(tools_router, prefix=settings.api_v1_prefix) + @app.get("/health") async def health() -> JSONResponse: diff --git a/apps/api/app/tools/__init__.py b/apps/api/app/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/tools/manifests/__init__.py b/apps/api/app/tools/manifests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/tools/manifests/code-server.yml b/apps/api/app/tools/manifests/code-server.yml new file mode 100644 index 0000000..1270a48 --- /dev/null +++ b/apps/api/app/tools/manifests/code-server.yml @@ -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 diff --git a/apps/api/app/tools/manifests/runfusion.yml b/apps/api/app/tools/manifests/runfusion.yml new file mode 100644 index 0000000..96c6e09 --- /dev/null +++ b/apps/api/app/tools/manifests/runfusion.yml @@ -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 diff --git a/apps/api/app/tools/models.py b/apps/api/app/tools/models.py new file mode 100644 index 0000000..3bc466a --- /dev/null +++ b/apps/api/app/tools/models.py @@ -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 diff --git a/apps/api/app/tools/registry.py b/apps/api/app/tools/registry.py new file mode 100644 index 0000000..7d5222e --- /dev/null +++ b/apps/api/app/tools/registry.py @@ -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() diff --git a/apps/api/app/tools/router.py b/apps/api/app/tools/router.py new file mode 100644 index 0000000..07cf8c4 --- /dev/null +++ b/apps/api/app/tools/router.py @@ -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 diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 29885ec..2aa6364 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "pyjwt>=2.8.0", "cryptography>=44.0.0", "httpx>=0.28.0", + "pyyaml>=6.0", ] [project.optional-dependencies] @@ -46,6 +47,10 @@ strict = true warn_return_any = true warn_unused_configs = true exclude = ["alembic/versions"] +plugins = ["pydantic.mypy"] +[[tool.mypy.overrides]] +module = "yaml" +ignore_missing_imports = true [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/apps/api/tests/tools/test_models.py b/apps/api/tests/tools/test_models.py new file mode 100644 index 0000000..e7b6fed --- /dev/null +++ b/apps/api/tests/tools/test_models.py @@ -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" diff --git a/apps/api/tests/tools/test_registry.py b/apps/api/tests/tools/test_registry.py new file mode 100644 index 0000000..315cd6f --- /dev/null +++ b/apps/api/tests/tools/test_registry.py @@ -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) diff --git a/apps/api/tests/tools/test_router.py b/apps/api/tests/tools/test_router.py new file mode 100644 index 0000000..c46f912 --- /dev/null +++ b/apps/api/tests/tools/test_router.py @@ -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 diff --git a/apps/web/.npmrc b/apps/web/.npmrc new file mode 100644 index 0000000..38e4d30 --- /dev/null +++ b/apps/web/.npmrc @@ -0,0 +1 @@ +ignore-build-scripts=false diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 6246aa2..c9e6353 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -9,9 +9,9 @@ RUN npm install -g pnpm@11.1.1 \ COPY . . RUN pnpm build -FROM nginx:alpine +FROM nginxinc/nginx-unprivileged:alpine COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 80 +EXPOSE 8080 diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf index 7e7c1b8..30848d5 100644 --- a/apps/web/nginx.conf +++ b/apps/web/nginx.conf @@ -1,5 +1,5 @@ server { - listen 80; + listen 8080; server_name localhost; root /usr/share/nginx/html; index index.html; @@ -14,6 +14,8 @@ server { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; 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; } } diff --git a/apps/web/package.json b/apps/web/package.json index bc4c599..a4d515b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,6 +3,9 @@ "version": "0.0.1", "private": true, "type": "module", + "pnpm": { + "onlyBuiltDependencies": ["esbuild"] + }, "scripts": { "dev": "vite", "build": "tsc -b && vite build", diff --git a/docker-compose.yml b/docker-compose.yml index b987b78..076d25f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ services: context: ./apps/web dockerfile: Dockerfile ports: - - "5173:80" + - "5173:8080" depends_on: - api restart: unless-stopped @@ -41,7 +41,7 @@ services: volumes: - postgres-data:/var/lib/postgresql/data ports: - - "5432:5432" + - "127.0.0.1:5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-headquarter}"] interval: 5s diff --git a/docs/architecture.md b/docs/architecture.md index b5e114a..a9bc564 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -166,26 +166,19 @@ https://code-myapp-alice.tools.example.com ## 7. Tool Manifest & Orchestration -Tools are defined by manifests (FN-003) that declare: +Tools are defined by manifest files validated against the canonical Pydantic v2 schema. +The full schema reference, validation rules, and extension guide are documented in +[`docs/tool-manifest-spec.md`](./tool-manifest-spec.md). -- Runtime image / image tag -- Node/npm version expectations (for executable environments) -- Bootstrap / install commands -- Command execution needs -- Workspace mounts -- Config mounts -- Environment variables -- Secrets -- Ports -- Health checks -- Resource limits -- Traefik routing needs (subdomain pattern, middleware) +**Summary:** Each manifest declares the runtime image, ports, workspace/config mounts, +environment variables, secrets, health checks, resource limits, and Traefik routing rules. +The orchestration backend reads these manifests and generates Docker Compose service +definitions, Traefik labels, and runtime volume/secret bindings. -The platform reads manifests and generates: -- Docker Compose service definitions -- Traefik labels for routing -- Volume mounts for workspace and config -- Secret injection at runtime +Built-in manifests for RunFusion and code-server are shipped in +`apps/api/app/tools/manifests/` and loaded automatically on API startup. +New standard-container tools can be added by creating a YAML manifest—no backend code +changes are required. ## 8. MVP Phases diff --git a/docs/tool-manifest-spec.md b/docs/tool-manifest-spec.md new file mode 100644 index 0000000..7763552 --- /dev/null +++ b/docs/tool-manifest-spec.md @@ -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` | 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: 1–65535. | +| `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. diff --git a/package.json b/package.json index 1393fea..98bfe4e 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ "compose:up": "docker compose up --build -d", "compose:down": "docker compose down" }, + "pnpm": { + "onlyBuiltDependencies": ["esbuild"] + }, "packageManager": "pnpm@11.1.1", "engines": { "node": ">=20",