feat: implement docker infrastructure (US-001)

- Add docker-compose.yml with postgres, redis, api, and web services
- Add multi-stage Dockerfile for API (Python 3.11)
- Add multi-stage Dockerfile for web (Node.js 20 + nginx)
- Add Makefile with common development commands
- Add .env.example with all required environment variables
- Add placeholder pyproject.toml and package.json for builds
- Configure health checks for all services
- Setup persistent volumes for postgres, redis, and repos
- Run services as non-root users
This commit is contained in:
2026-05-16 17:44:39 +00:00
parent 212d072417
commit e7819bfc82
246 changed files with 3625 additions and 17311 deletions
View File
-90
View File
@@ -1,90 +0,0 @@
from collections.abc import AsyncGenerator, Generator
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.pool import NullPool
from app.config import settings
from app.db import get_db_session
from app.main import app
from app.models import Base
TEST_DATABASE_URL = settings.database_url
if "/headquarter_test" not in TEST_DATABASE_URL:
TEST_DATABASE_URL = TEST_DATABASE_URL.replace("/headquarter", "/headquarter_test")
if TEST_DATABASE_URL.startswith("postgresql://"):
TEST_DATABASE_URL = TEST_DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://", 1)
@pytest.fixture(scope="session")
def event_loop() -> Generator[Any, None, None]:
import asyncio
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="session")
async def db_engine() -> AsyncGenerator[AsyncEngine, None]:
engine = create_async_engine(TEST_DATABASE_URL, echo=False, poolclass=NullPool)
try:
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
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest.fixture
async def db_session(
db_engine: AsyncEngine,
) -> AsyncGenerator[async_sessionmaker[AsyncSession], None]:
async with db_engine.connect() as connection:
trans = await connection.begin_nested()
testing_session_local = async_sessionmaker(
connection, class_=AsyncSession, expire_on_commit=False
)
async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
async with testing_session_local() as session:
yield session
app.dependency_overrides[get_db_session] = override_get_db
original_db_url = settings.database_url
settings.database_url = TEST_DATABASE_URL
yield testing_session_local
settings.database_url = original_db_url
app.dependency_overrides.pop(get_db_session, None)
await trans.rollback()
@pytest.fixture
async def client(
db_session: async_sessionmaker[AsyncSession],
) -> AsyncGenerator[AsyncClient, None]:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
@pytest.fixture
async def auth_client(
client: AsyncClient,
) -> AsyncGenerator[AsyncClient, None]:
original_debug = settings.debug
original_bypass = settings.auth_dev_bypass
settings.debug = True
settings.auth_dev_bypass = True
yield client
settings.debug = original_debug
settings.auth_dev_bypass = original_bypass
-87
View File
@@ -1,87 +0,0 @@
from typing import Any
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.config import settings
from app.models.user import User
@pytest.mark.asyncio
async def test_dev_bypass_creates_user(auth_client: AsyncClient) -> None:
response = await auth_client.get("/api/v1/users/me")
assert response.status_code == 200
data = response.json()
assert data["authentik_sub"] == "dev-user"
@pytest.mark.asyncio
async def test_missing_token_raises_401_when_bypass_disabled(client: AsyncClient) -> None:
original_debug = settings.debug
original_bypass = settings.auth_dev_bypass
settings.debug = False
settings.auth_dev_bypass = False
response = await client.get("/api/v1/users/me")
settings.debug = original_debug
settings.auth_dev_bypass = original_bypass
assert response.status_code == 401
@pytest.mark.asyncio
async def test_inactive_user_raises_403(
auth_client: AsyncClient, db_session: async_sessionmaker[Any]
) -> None:
async with db_session() as session:
result = await session.execute(select(User).where(User.authentik_sub == "dev-user"))
user = result.scalar_one_or_none()
if user is None:
user = User(
authentik_sub="dev-user",
email="dev@localhost",
display_name="Dev User",
is_active=True,
)
session.add(user)
await session.commit()
async with db_session() as session:
result = await session.execute(select(User).where(User.authentik_sub == "dev-user"))
user = result.scalar_one()
user.is_active = False
await session.commit()
response = await auth_client.get("/api/v1/users/me")
async with db_session() as session:
result = await session.execute(select(User).where(User.authentik_sub == "dev-user"))
user = result.scalar_one()
user.is_active = True
await session.commit()
assert response.status_code == 403
@pytest.mark.asyncio
async def test_dev_bypass_email_uniqueness(
auth_client: AsyncClient, db_session: async_sessionmaker[Any]
) -> None:
async with db_session() as session:
result = await session.execute(select(User).where(User.email == "dev@localhost"))
existing = result.scalar_one_or_none()
if existing:
await session.delete(existing)
await session.commit()
response = await auth_client.get("/api/v1/users/me")
assert response.status_code == 200
async with db_session() as session:
result = await session.execute(select(User).where(User.email == "dev@localhost"))
user = result.scalar_one_or_none()
assert user is not None
assert user.authentik_sub == "dev-user"
-77
View File
@@ -1,77 +0,0 @@
"""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
-147
View File
@@ -1,147 +0,0 @@
"""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_invalid_repo_raises(local_git: LocalGitOperations) -> None:
with pytest.raises(RuntimeError, match="Git clone failed"):
local_git.clone("https://example.com/repo.git", Path("/tmp/dest"), "cred-id")
def test_fetch_no_remote_succeeds(local_git: LocalGitOperations, temp_repo: Path) -> None:
local_git.fetch(temp_repo, "cred-id")
def test_push_no_remote_raises(local_git: LocalGitOperations, temp_repo: Path) -> None:
with pytest.raises(RuntimeError, match="Git push failed"):
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
@@ -1,66 +0,0 @@
"""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
-14
View File
@@ -1,14 +0,0 @@
import pytest
from httpx import AsyncClient
from app.config import settings
@pytest.mark.asyncio
async def test_health_returns_ok(client: AsyncClient) -> None:
response = await client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["service"] == settings.app_name
assert data["database"] == "connected"
@@ -1,35 +0,0 @@
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_project_crud(auth_client: AsyncClient) -> None:
# Create
resp = await auth_client.post("/api/v1/projects", json={"name": "Test", "slug": "test"})
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Test"
project_id = data["id"]
# List
resp = await auth_client.get("/api/v1/projects")
assert resp.status_code == 200
assert len(resp.json()) == 1
# Get
resp = await auth_client.get(f"/api/v1/projects/{project_id}")
assert resp.status_code == 200
assert resp.json()["slug"] == "test"
# Update
resp = await auth_client.put(f"/api/v1/projects/{project_id}", json={"name": "Updated"})
assert resp.status_code == 200
assert resp.json()["name"] == "Updated"
# Delete
resp = await auth_client.delete(f"/api/v1/projects/{project_id}")
assert resp.status_code == 204
# Verify deletion
resp = await auth_client.get(f"/api/v1/projects/{project_id}")
assert resp.status_code == 404
@@ -1,40 +0,0 @@
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_repository_crud(auth_client: AsyncClient) -> None:
# Create project first
resp = await auth_client.post(
"/api/v1/projects", json={"name": "RepoTest", "slug": "repo-test"}
)
project_id = resp.json()["id"]
# Create repo
resp = await auth_client.post(
f"/api/v1/projects/{project_id}/repositories",
json={"name": "repo1", "git_url": "https://git.example.com/repo1.git"},
)
assert resp.status_code == 201
repo_id = resp.json()["id"]
# List
resp = await auth_client.get(f"/api/v1/projects/{project_id}/repositories")
assert resp.status_code == 200
assert len(resp.json()) == 1
# Get
resp = await auth_client.get(f"/api/v1/projects/{project_id}/repositories/{repo_id}")
assert resp.status_code == 200
# Update
resp = await auth_client.put(
f"/api/v1/projects/{project_id}/repositories/{repo_id}",
json={"name": "repo1-updated"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "repo1-updated"
# Delete
resp = await auth_client.delete(f"/api/v1/projects/{project_id}/repositories/{repo_id}")
assert resp.status_code == 204
@@ -1,72 +0,0 @@
from typing import Any
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.models.secret import Secret
@pytest.mark.asyncio
async def test_secret_encrypt_decrypt(
auth_client: AsyncClient, db_session: async_sessionmaker[Any]
) -> None:
resp = await auth_client.post(
"/api/v1/projects",
json={"name": "SecretTest", "slug": "secret-test"},
)
project_id = resp.json()["id"]
resp = await auth_client.post(
"/api/v1/secrets",
json={
"scope_type": "project",
"scope_id": str(project_id),
"key": "api_key",
"value": "super-secret",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["value"] == "super-secret"
secret_id = data["id"]
resp = await auth_client.get(f"/api/v1/secrets/{secret_id}")
assert resp.status_code == 200
assert resp.json()["value"] == "super-secret"
async with db_session() as session:
result = await session.execute(select(Secret).where(Secret.id == secret_id))
secret = result.scalar_one()
assert secret.encrypted_value != "super-secret"
resp = await auth_client.put(
f"/api/v1/secrets/{secret_id}",
json={"value": "new-secret"},
)
assert resp.status_code == 200
assert resp.json()["value"] == "new-secret"
resp = await auth_client.delete(f"/api/v1/secrets/{secret_id}")
assert resp.status_code == 204
@pytest.mark.asyncio
async def test_secret_ownership_enforced(auth_client: AsyncClient) -> None:
resp = await auth_client.post("/api/v1/projects", json={"name": "P1", "slug": "p1"})
p1 = resp.json()["id"]
resp = await auth_client.post("/api/v1/projects", json={"name": "P2", "slug": "p2"})
p2 = resp.json()["id"]
resp = await auth_client.post(
"/api/v1/secrets",
json={"scope_type": "project", "scope_id": str(p1), "key": "k1", "value": "v1"},
)
resp = await auth_client.get("/api/v1/secrets", params={"scope_id": str(p2)})
assert resp.status_code == 200
secrets = resp.json()
for s in secrets:
assert s["scope_id"] != str(p1) or s["scope_type"] != "project"
@@ -1,30 +0,0 @@
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_tool_definition_crud(auth_client: AsyncClient) -> None:
resp = await auth_client.post(
"/api/v1/tool-definitions",
json={
"key": "opencode",
"name": "OpenCode",
"image": "ghcr.io/opencode-ai/opencode:latest",
},
)
assert resp.status_code == 201
td_id = resp.json()["id"]
resp = await auth_client.get("/api/v1/tool-definitions")
assert resp.status_code == 200
assert len(resp.json()) >= 1
resp = await auth_client.get(f"/api/v1/tool-definitions/{td_id}")
assert resp.status_code == 200
resp = await auth_client.put(f"/api/v1/tool-definitions/{td_id}", json={"name": "OpenCodeV2"})
assert resp.status_code == 200
assert resp.json()["name"] == "OpenCodeV2"
resp = await auth_client.delete(f"/api/v1/tool-definitions/{td_id}")
assert resp.status_code == 204
-110
View File
@@ -1,110 +0,0 @@
from uuid import UUID
import pytest
from app.services.runtime_injection import RuntimeInjectionError, RuntimeInjectionService
@pytest.mark.asyncio
async def test_resolve_configs_empty(db_session):
result = await RuntimeInjectionService.resolve_configs(
db_session,
project_id=UUID(int=1),
user_id=UUID(int=2),
)
assert result == {}
@pytest.mark.asyncio
async def test_resolve_configs_global_only(db_session, sample_config):
result = await RuntimeInjectionService.resolve_configs(
db_session,
project_id=UUID(int=1),
user_id=UUID(int=2),
)
assert result == {"test_key": "test_value"}
@pytest.mark.asyncio
async def test_resolve_configs_scope_override(db_session):
from app.models.config import Config
global_config = Config(
scope_type="global",
scope_id=UUID(int=0),
key="shared_key",
value="global_value",
)
project_config = Config(
scope_type="project",
scope_id=UUID(int=1),
key="shared_key",
value="project_value",
)
db_session.add_all([global_config, project_config])
await db_session.commit()
result = await RuntimeInjectionService.resolve_configs(
db_session,
project_id=UUID(int=1),
user_id=UUID(int=2),
)
assert result["shared_key"] == "project_value"
@pytest.mark.asyncio
async def test_resolve_secrets_empty(db_session):
result = await RuntimeInjectionService.resolve_secrets(
db_session,
project_id=UUID(int=1),
user_id=UUID(int=2),
)
assert result == {}
@pytest.mark.asyncio
async def test_resolve_secrets_decrypts(db_session, sample_secret):
result = await RuntimeInjectionService.resolve_secrets(
db_session,
project_id=UUID(int=1),
user_id=UUID(int=2),
)
assert result == {"secret_key": "secret_value"}
@pytest.mark.asyncio
async def test_validate_secrets_exist_missing(db_session):
with pytest.raises(RuntimeInjectionError, match="Missing required secrets"):
await RuntimeInjectionService.validate_secrets_exist(
db_session,
required_secret_keys=["missing_secret"],
project_id=UUID(int=1),
user_id=UUID(int=2),
)
@pytest.mark.asyncio
async def test_validate_secrets_exist_found(db_session, sample_secret):
await RuntimeInjectionService.validate_secrets_exist(
db_session,
required_secret_keys=["secret_key"],
project_id=UUID(int=1),
user_id=UUID(int=2),
)
def test_generate_config_files(tmp_path):
configs = {"app": {"port": 8080}, "debug": True}
mounts = RuntimeInjectionService.generate_config_files(configs, tmp_path)
assert len(mounts) == 2
assert (tmp_path / "app.json").exists()
assert (tmp_path / "debug.json").exists()
assert (tmp_path / "app.json").stat().st_mode & 0o777 == 0o400
def test_generate_secret_env_vars():
secrets = {"api_key": "abc123", "db_pass": "secret"}
env_vars = RuntimeInjectionService.generate_secret_env_vars(secrets)
assert env_vars == {"API_KEY": "abc123", "DB_PASS": "secret"}
-197
View File
@@ -1,197 +0,0 @@
from app.services.traefik import TraefikLabelGenerator
class TestTraefikLabelGenerator:
def test_generate_subdomain(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
subdomain = gen.generate_subdomain(
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
)
assert subdomain == "code-server-my-project-alice.hq.example.com"
def test_generate_subdomain_with_different_domain(self):
gen = TraefikLabelGenerator(domain="tools.localhost")
subdomain = gen.generate_subdomain(
tool_key="opencode",
project_slug="test",
user_slug="bob",
)
assert subdomain == "opencode-test-bob.tools.localhost"
def test_generate_labels_basic(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
assert labels["traefik.enable"] == "true"
assert "Host(`code-server-my-project-alice.hq.example.com`)" in labels[
"traefik.http.routers.tool-abc12345.rule"
]
assert labels["traefik.http.routers.tool-abc12345.entrypoints"] == "websecure"
assert labels["traefik.http.routers.tool-abc12345.service"] == "tool-abc12345"
def test_generate_labels_tls(self):
gen = TraefikLabelGenerator(domain="hq.example.com", entrypoint="websecure")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
assert labels["traefik.http.routers.tool-abc12345.tls"] == "true"
assert (
labels["traefik.http.routers.tool-abc12345.tls.certresolver"]
== "letsencrypt"
)
def test_generate_labels_no_tls_for_http(self):
gen = TraefikLabelGenerator(domain="hq.example.com", entrypoint="web")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
assert "traefik.http.routers.tool-abc12345.tls" not in labels
assert "traefik.http.routers.tool-abc12345.tls.certresolver" not in labels
def test_generate_labels_service_config(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
assert (
labels["traefik.http.services.tool-abc12345.loadbalancer.server.port"]
== "8443"
)
assert (
labels["traefik.http.services.tool-abc12345.loadbalancer.server.scheme"]
== "http"
)
def test_generate_labels_security_headers(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
middleware_name = "tool-abc12345-sec"
assert (
labels[f"traefik.http.middlewares.{middleware_name}.headers.stsSeconds"]
== "31536000"
)
assert (
labels[
f"traefik.http.middlewares.{middleware_name}.headers.stsIncludeSubdomains"
]
== "true"
)
assert (
labels[
f"traefik.http.middlewares.{middleware_name}.headers.forceStsHeader"
]
== "true"
)
assert (
labels[
f"traefik.http.middlewares.{middleware_name}.headers.contentTypeNosniff"
]
== "true"
)
assert (
labels[
f"traefik.http.middlewares.{middleware_name}.headers.browserXssFilter"
]
== "true"
)
assert (
labels[
f"traefik.http.middlewares.{middleware_name}.headers.customFrameOptionsValue"
]
== "SAMEORIGIN"
)
def test_generate_labels_middleware_attached(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
assert labels["traefik.http.routers.tool-abc12345.middlewares"] == "tool-abc12345-sec"
def test_generate_labels_network(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
network_name="custom-network",
)
assert labels["traefik.docker.network"] == "custom-network"
def test_generate_labels_default_network(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
assert labels["traefik.docker.network"] == "tools"
def test_generate_removal_labels(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_removal_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
)
assert labels["traefik.enable"] == "false"
assert labels["traefik.http.routers.tool-abc12345.rule"] == ""
def test_generate_labels_with_opencode(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="xyz78901-2345-6789-0123-456789012345",
tool_key="opencode",
project_slug="demo",
user_slug="charlie",
container_port=3000,
)
assert "Host(`opencode-demo-charlie.hq.example.com`)" in labels[
"traefik.http.routers.tool-xyz78901.rule"
]
assert (
labels["traefik.http.services.tool-xyz78901.loadbalancer.server.port"]
== "3000"
)
-241
View File
@@ -1,241 +0,0 @@
"""Tests for the tool manifest Pydantic models."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.tools.models import (
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_opencode_shape() -> None:
manifest = ToolManifest(
id="opencode",
name="OpenCode",
description="AI-powered terminal-based development environment.",
image="ghcr.io/opencode-ai/opencode:latest",
runtime_working_dir="/workspace",
ports=[PortConfig(container_port=3000, name="http", primary=True)],
workspace_mounts=[
MountConfig(source_pattern="{project_repo}", target="/workspace")
],
config_mounts=[
MountConfig(
source_pattern="{user_config}/opencode", target="/root/.config/opencode"
)
],
env={"TERM": "xterm-256color", "FORCE_COLOR": "1"},
health_check=HealthCheckConfig(
type="http", path="/", port=3000, start_period_seconds=15
),
resource_limits=ResourceLimits(cpus=2.0, memory_mb=4096),
traefik=TraefikConfig(
enabled=True, subdomain_prefix="opencode", port=3000
),
)
assert manifest.id == "opencode"
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="OpenCode")
assert "id" in str(exc_info.value)
def test_invalid_id_spaces() -> None:
with pytest.raises(ValidationError) as exc_info:
_minimal_manifest(id="open code")
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
@@ -1,159 +0,0 @@
"""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
@@ -1,93 +0,0 @@
"""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 "opencode" in ids
assert "code-server" in ids
# ---------------------------------------------------------------------------
# GET /api/v1/tools/{tool_id}
# ---------------------------------------------------------------------------
def test_get_tool_opencode() -> None:
response = client.get("/api/v1/tools/opencode")
assert response.status_code == 200
data = response.json()
assert data["id"] == "opencode"
assert data["name"] == "OpenCode"
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": "opencode",
"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