diff --git a/apps/api/app/auth/dependencies.py b/apps/api/app/auth/dependencies.py index 3f5a6bf..4e8ae34 100644 --- a/apps/api/app/auth/dependencies.py +++ b/apps/api/app/auth/dependencies.py @@ -91,3 +91,46 @@ async def get_current_active_user( detail="Inactive user", ) return current_user + + +async def validate_traefik_auth( + token: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + session: AsyncSession = Depends(get_db_session), +) -> User: + if token is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + claims = decode_token(token.credentials) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Invalid token: {exc}", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + + authentik_sub = claims.get("sub") + if not authentik_sub: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token missing 'sub' claim", + headers={"WWW-Authenticate": "Bearer"}, + ) + + result = await session.execute( + select(User).where(User.authentik_sub == authentik_sub) + ) + user = result.scalar_one_or_none() + + if user is None or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or inactive", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return user diff --git a/apps/api/app/models/tool_instance.py b/apps/api/app/models/tool_instance.py index 219024a..5ea3bfa 100644 --- a/apps/api/app/models/tool_instance.py +++ b/apps/api/app/models/tool_instance.py @@ -34,6 +34,9 @@ class ToolInstance(Base, UUIDMixin, TimestampMixin): config_override: Mapped[dict[str, Any] | None] = mapped_column( JSON, nullable=True ) + traefik_labels: Mapped[dict[str, Any] | None] = mapped_column( + JSON, nullable=True + ) project: Mapped["Project"] = relationship( back_populates="tool_instances" diff --git a/apps/api/app/routers/tool_instances.py b/apps/api/app/routers/tool_instances.py index cbbd7ec..60e95c9 100644 --- a/apps/api/app/routers/tool_instances.py +++ b/apps/api/app/routers/tool_instances.py @@ -5,11 +5,16 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.dependencies import get_current_active_user +from app.config import settings from app.db import get_db_session from app.models.project import Project +from app.models.tool_definition import ToolDefinition from app.models.tool_instance import ToolInstance from app.models.user import User from app.schemas.tool_instance import ToolInstanceCreate, ToolInstanceRead, ToolInstanceUpdate +from app.services.spawn import SpawnError, SpawnService +from app.services.traefik import TraefikLabelGenerator +from app.tools.registry import registry router = APIRouter(tags=["tool-instances"]) @@ -23,22 +28,95 @@ async def _get_project_for_user( return project -@router.post("/projects/{project_id}/tool-instances", response_model=ToolInstanceRead, status_code=status.HTTP_201_CREATED) # noqa: E501 +def _get_user_slug(user: User) -> str: + user_slug = ( + user.display_name + or user.email.split("@")[0] + if user.email + else "user" + ) + return user_slug.lower().replace(" ", "-").replace("_", "-") + + +@router.post( + "/projects/{project_id}/tool-instances", + response_model=ToolInstanceRead, + status_code=status.HTTP_201_CREATED, +) async def create_tool_instance( project_id: UUID, ti_in: ToolInstanceCreate, current_user: User = Depends(get_current_active_user), session: AsyncSession = Depends(get_db_session), ) -> ToolInstance: - await _get_project_for_user(project_id, current_user, session) + project = await _get_project_for_user(project_id, current_user, session) + tool_def = await session.get(ToolDefinition, ti_in.tool_definition_id) + if not tool_def: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tool definition not found", + ) + + manifest = registry.get(tool_def.key) + if not manifest: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Tool manifest '{tool_def.key}' not found in registry", + ) + + existing = await session.execute( + select(ToolInstance).where( + ToolInstance.project_id == project_id, + ToolInstance.tool_definition_id == ti_in.tool_definition_id, + ToolInstance.status.in_(["creating", "running"]), + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A running instance of this tool already exists for this project", + ) + ti = ToolInstance(**ti_in.model_dump(), project_id=project_id) + user_slug = _get_user_slug(current_user) + + spawn_service = SpawnService() + label_gen = TraefikLabelGenerator(domain=settings.root_domain) + + try: + spawn_result = spawn_service.spawn( + instance_id=str(ti.id), + manifest=manifest, + project_slug=project.slug, + user_slug=user_slug, + ) + except SpawnError as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to spawn container: {e}", + ) from e + + auth_labels = label_gen.generate_forward_auth_labels( + instance_id=str(ti.id), + auth_url=f"https://{settings.root_domain}/api/v1/auth/validate", + ) + traefik_labels = {**spawn_result["traefik_labels"], **auth_labels} + + ti.container_id = spawn_result["container_id"] + ti.subdomain = spawn_result["subdomain"] + ti.traefik_labels = traefik_labels + ti.status = spawn_service.get_status(str(ti.id)) + session.add(ti) await session.commit() await session.refresh(ti) return ti -@router.get("/projects/{project_id}/tool-instances", response_model=list[ToolInstanceRead]) +@router.get( + "/projects/{project_id}/tool-instances", + response_model=list[ToolInstanceRead], +) async def list_tool_instances( project_id: UUID, current_user: User = Depends(get_current_active_user), @@ -51,7 +129,10 @@ async def list_tool_instances( return list(result.scalars().all()) -@router.get("/projects/{project_id}/tool-instances/{instance_id}", response_model=ToolInstanceRead) +@router.get( + "/projects/{project_id}/tool-instances/{instance_id}", + response_model=ToolInstanceRead, +) async def get_tool_instance( project_id: UUID, instance_id: UUID, @@ -61,11 +142,17 @@ async def get_tool_instance( await _get_project_for_user(project_id, current_user, session) ti = await session.get(ToolInstance, instance_id) if not ti or ti.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tool instance not found", + ) return ti -@router.put("/projects/{project_id}/tool-instances/{instance_id}", response_model=ToolInstanceRead) +@router.put( + "/projects/{project_id}/tool-instances/{instance_id}", + response_model=ToolInstanceRead, +) async def update_tool_instance( project_id: UUID, instance_id: UUID, @@ -76,7 +163,10 @@ async def update_tool_instance( await _get_project_for_user(project_id, current_user, session) ti = await session.get(ToolInstance, instance_id) if not ti or ti.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tool instance not found", + ) update_data = ti_in.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(ti, field, value) @@ -85,7 +175,10 @@ async def update_tool_instance( return ti -@router.delete("/projects/{project_id}/tool-instances/{instance_id}", status_code=status.HTTP_204_NO_CONTENT) # noqa: E501 +@router.delete( + "/projects/{project_id}/tool-instances/{instance_id}", + status_code=status.HTTP_204_NO_CONTENT, +) async def delete_tool_instance( project_id: UUID, instance_id: UUID, @@ -95,6 +188,140 @@ async def delete_tool_instance( await _get_project_for_user(project_id, current_user, session) ti = await session.get(ToolInstance, instance_id) if not ti or ti.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tool instance not found", + ) + + spawn_service = SpawnService() + spawn_service.stop(str(instance_id)) + await session.delete(ti) await session.commit() + + +@router.post( + "/projects/{project_id}/tool-instances/{instance_id}/stop", + response_model=ToolInstanceRead, +) +async def stop_tool_instance( + project_id: UUID, + instance_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> ToolInstance: + await _get_project_for_user(project_id, current_user, session) + ti = await session.get(ToolInstance, instance_id) + if not ti or ti.project_id != project_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tool instance not found", + ) + + spawn_service = SpawnService() + spawn_service.stop(str(instance_id)) + + ti.status = "stopped" + ti.container_id = None + + await session.commit() + await session.refresh(ti) + return ti + + +@router.post( + "/projects/{project_id}/tool-instances/{instance_id}/start", + response_model=ToolInstanceRead, +) +async def start_tool_instance( + project_id: UUID, + instance_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> ToolInstance: + await _get_project_for_user(project_id, current_user, session) + ti = await session.get(ToolInstance, instance_id) + if not ti or ti.project_id != project_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tool instance not found", + ) + + tool_def = await session.get(ToolDefinition, ti.tool_definition_id) + if not tool_def: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tool definition not found", + ) + + manifest = registry.get(tool_def.key) + if not manifest: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Tool manifest '{tool_def.key}' not found in registry", + ) + + user_slug = _get_user_slug(current_user) + spawn_service = SpawnService() + + try: + spawn_result = spawn_service.spawn( + instance_id=str(ti.id), + manifest=manifest, + project_slug=ti.project.slug, + user_slug=user_slug, + ) + except SpawnError as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to spawn container: {e}", + ) from e + + ti.container_id = spawn_result["container_id"] + ti.subdomain = spawn_result["subdomain"] + ti.traefik_labels = spawn_result["traefik_labels"] + ti.status = spawn_service.get_status(str(ti.id)) + + await session.commit() + await session.refresh(ti) + return ti + + +@router.get( + "/projects/{project_id}/tool-instances/{instance_id}/status", + response_model=dict, +) +async def get_tool_instance_status( + project_id: UUID, + instance_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> dict[str, str]: + await _get_project_for_user(project_id, current_user, session) + ti = await session.get(ToolInstance, instance_id) + if not ti or ti.project_id != project_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tool instance not found", + ) + + spawn_service = SpawnService() + container_status = spawn_service.get_status(str(instance_id)) + + if ti.status != container_status: + ti.status = container_status + await session.commit() + + return { + "instance_id": str(instance_id), + "status": container_status, + "subdomain": ti.subdomain or "", + "container_id": ti.container_id or "", + } + + +@router.get("/auth/validate", status_code=status.HTTP_200_OK) +async def validate_auth_for_traefik( + current_user: User = Depends(get_current_active_user), +) -> dict[str, str]: + return {"status": "ok", "user_id": str(current_user.id)} diff --git a/apps/api/app/schemas/tool_instance.py b/apps/api/app/schemas/tool_instance.py index 347ae4c..348ea2d 100644 --- a/apps/api/app/schemas/tool_instance.py +++ b/apps/api/app/schemas/tool_instance.py @@ -10,6 +10,7 @@ class ToolInstanceBase(OrmBase): container_id: str | None = None subdomain: str | None = None config_override: dict[str, Any] | None = None + traefik_labels: dict[str, Any] | None = None class ToolInstanceCreate(ToolInstanceBase): @@ -28,3 +29,4 @@ class ToolInstanceUpdate(OrmBase): container_id: str | None = None subdomain: str | None = None config_override: dict[str, Any] | None = None + traefik_labels: dict[str, Any] | None = None diff --git a/apps/api/app/services/spawn.py b/apps/api/app/services/spawn.py new file mode 100644 index 0000000..2bdcd4f --- /dev/null +++ b/apps/api/app/services/spawn.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +import json +import logging +import subprocess +from pathlib import Path +from typing import Any + +from app.config import settings +from app.services.traefik import TraefikLabelGenerator +from app.tools.models import ToolManifest + +logger = logging.getLogger(__name__) + + +class SpawnError(Exception): + pass + + +class SpawnService: + def __init__( + self, + compose_dir: Path | None = None, + network_name: str = "tools", + ) -> None: + self.compose_dir = compose_dir or Path("/tmp/headquarter-compose") + self.network_name = network_name + self.compose_dir.mkdir(parents=True, exist_ok=True) + + def _generate_compose_service( + self, + instance_id: str, + manifest: ToolManifest, + subdomain: str, + traefik_labels: dict[str, str], + project_slug: str, + user_slug: str, + workspace_path: Path | None = None, + config_path: Path | None = None, + ssh_key_path: Path | None = None, + ) -> dict[str, Any]: + service_name = f"tool-{instance_id[:8]}" + + service: dict[str, Any] = { + "image": manifest.image, + "container_name": service_name, + "restart": "unless-stopped", + "labels": traefik_labels, + "networks": [self.network_name], + } + + if manifest.runtime_command: + service["command"] = manifest.runtime_command + + if manifest.runtime_entrypoint: + service["entrypoint"] = manifest.runtime_entrypoint + + if manifest.runtime_user: + service["user"] = manifest.runtime_user + + if manifest.runtime_working_dir: + service["working_dir"] = manifest.runtime_working_dir + + ports = manifest.ports + if ports: + service["ports"] = [ + f"{port.container_port}:{port.container_port}" + for port in ports + ] + + env = dict(manifest.env) + env.update({ + "PROJECT_SLUG": project_slug, + "USER_SLUG": user_slug, + }) + service["environment"] = env + + volumes: list[str] = [] + + default_workspace = f"/data/workspaces/{user_slug}/{project_slug}" + for mount in manifest.workspace_mounts: + source = mount.source_pattern.format( + project_repo=str(workspace_path) if workspace_path else default_workspace, + ) + ro_suffix = ":ro" if mount.read_only else "" + volumes.append(f"{source}:{mount.target}{ro_suffix}") + + default_config = f"/data/configs/{user_slug}" + for mount in manifest.config_mounts: + source = mount.source_pattern.format( + user_config=str(config_path) if config_path else default_config, + ) + ro_suffix = ":ro" if mount.read_only else "" + volumes.append(f"{source}:{mount.target}{ro_suffix}") + + if ssh_key_path and ssh_key_path.exists(): + volumes.append(f"{ssh_key_path}:/home/coder/.ssh:ro") + + if volumes: + service["volumes"] = volumes + + if manifest.health_check: + hc = manifest.health_check + healthcheck: dict[str, Any] = { + "interval": f"{hc.interval_seconds}s", + "timeout": f"{hc.timeout_seconds}s", + "retries": hc.retries, + "start_period": f"{hc.start_period_seconds}s", + } + + if hc.type == "http": + healthcheck["test"] = [ + "CMD", + "curl", + "-f", + f"http://localhost:{hc.port}{hc.path}", + ] + elif hc.type == "tcp": + healthcheck["test"] = [ + "CMD", + "nc", + "-z", + "localhost", + str(hc.port), + ] + elif hc.type == "command": + healthcheck["test"] = ["CMD"] + (hc.command or []) + + service["healthcheck"] = healthcheck + + if manifest.resource_limits: + rl = manifest.resource_limits + deploy: dict[str, Any] = {"resources": {"limits": {}}} + if rl.cpus: + deploy["resources"]["limits"]["cpus"] = str(rl.cpus) + if rl.memory_mb: + deploy["resources"]["limits"]["memory"] = f"{rl.memory_mb}M" + if rl.memory_swap_mb is not None and rl.memory_swap_mb >= 0: + deploy["resources"]["limits"]["swap"] = f"{rl.memory_swap_mb}M" + service["deploy"] = deploy + + return service + + def _write_compose_file( + self, + instance_id: str, + service: dict[str, Any], + ) -> Path: + compose_path = self.compose_dir / f"{instance_id}.yml" + + compose = { + "version": "3.8", + "services": {f"tool-{instance_id[:8]}": service}, + "networks": { + self.network_name: { + "external": True, + }, + }, + } + + compose_path.write_text(json.dumps(compose, indent=2)) + return compose_path + + def spawn( + self, + instance_id: str, + manifest: ToolManifest, + project_slug: str, + user_slug: str, + workspace_path: Path | None = None, + config_path: Path | None = None, + ssh_key_path: Path | None = None, + ) -> dict[str, Any]: + label_gen = TraefikLabelGenerator(domain=settings.root_domain) + + primary_port = next( + (p.container_port for p in manifest.ports if p.primary), + manifest.ports[0].container_port if manifest.ports else 8080, + ) + + subdomain = label_gen.generate_subdomain( + tool_key=manifest.id, + project_slug=project_slug, + user_slug=user_slug, + ) + + traefik_labels = label_gen.generate_labels( + instance_id=instance_id, + tool_key=manifest.id, + project_slug=project_slug, + user_slug=user_slug, + container_port=primary_port, + network_name=self.network_name, + ) + + service = self._generate_compose_service( + instance_id=instance_id, + manifest=manifest, + subdomain=subdomain, + traefik_labels=traefik_labels, + project_slug=project_slug, + user_slug=user_slug, + workspace_path=workspace_path, + config_path=config_path, + ssh_key_path=ssh_key_path, + ) + + compose_path = self._write_compose_file(instance_id, service) + + try: + result = subprocess.run( + [ + "docker", "compose", + "-f", str(compose_path), + "-p", f"hq-tool-{instance_id[:8]}", + "up", "-d", "--remove-orphans", + ], + capture_output=True, + text=True, + check=True, + ) + logger.info("Spawned container for instance %s: %s", instance_id, result.stdout) + except subprocess.CalledProcessError as e: + logger.error("Failed to spawn container for instance %s: %s", instance_id, e.stderr) + raise SpawnError(f"Failed to spawn container: {e.stderr}") from e + + container_id = self._get_container_id(instance_id) + + return { + "container_id": container_id, + "subdomain": subdomain, + "traefik_labels": traefik_labels, + "compose_path": str(compose_path), + } + + def stop(self, instance_id: str) -> None: + compose_path = self.compose_dir / f"{instance_id}.yml" + + if not compose_path.exists(): + logger.warning("Compose file not found for instance %s", instance_id) + return + + try: + subprocess.run( + [ + "docker", "compose", + "-f", str(compose_path), + "-p", f"hq-tool-{instance_id[:8]}", + "down", + ], + capture_output=True, + text=True, + check=True, + ) + logger.info("Stopped container for instance %s", instance_id) + except subprocess.CalledProcessError as e: + logger.error("Failed to stop container for instance %s: %s", instance_id, e.stderr) + raise SpawnError(f"Failed to stop container: {e.stderr}") from e + + def get_status(self, instance_id: str) -> str: + container_id = self._get_container_id(instance_id) + + if not container_id: + return "stopped" + + try: + result = subprocess.run( + [ + "docker", "inspect", + "-f", "{{.State.Status}}", + container_id, + ], + capture_output=True, + text=True, + check=True, + ) + status = result.stdout.strip() + + if status == "running": + health = self._get_health_status(container_id) + if health == "healthy": + return "running" + elif health == "unhealthy": + return "error" + else: + return "creating" + elif status in ("exited", "dead"): + return "stopped" + elif status == "paused": + return "stopped" + else: + return "creating" + except subprocess.CalledProcessError: + return "stopped" + + def _get_container_id(self, instance_id: str) -> str | None: + service_name = f"tool-{instance_id[:8]}" + project_name = f"hq-tool-{instance_id[:8]}" + + try: + result = subprocess.run( + [ + "docker", "compose", + "-p", project_name, + "ps", "-q", service_name, + ], + capture_output=True, + text=True, + check=True, + ) + container_id = result.stdout.strip() + return container_id if container_id else None + except subprocess.CalledProcessError: + return None + + def _get_health_status(self, container_id: str) -> str | None: + try: + result = subprocess.run( + [ + "docker", "inspect", + "-f", "{{.State.Health.Status}}", + container_id, + ], + capture_output=True, + text=True, + check=True, + ) + status = result.stdout.strip() + return status if status else None + except subprocess.CalledProcessError: + return None + + diff --git a/apps/api/app/services/traefik.py b/apps/api/app/services/traefik.py new file mode 100644 index 0000000..baacac2 --- /dev/null +++ b/apps/api/app/services/traefik.py @@ -0,0 +1,101 @@ +class TraefikLabelGenerator: + def __init__(self, domain: str, entrypoint: str = "websecure"): + self.domain = domain + self.entrypoint = entrypoint + + def generate_subdomain( + self, + tool_key: str, + project_slug: str, + user_slug: str, + ) -> str: + return f"{tool_key}-{project_slug}-{user_slug}.{self.domain}" + + def generate_labels( + self, + instance_id: str, + tool_key: str, + project_slug: str, + user_slug: str, + container_port: int, + network_name: str = "tools", + ) -> dict[str, str]: + subdomain = self.generate_subdomain(tool_key, project_slug, user_slug) + router_name = f"tool-{instance_id[:8]}" + service_name = f"tool-{instance_id[:8]}" + + labels: dict[str, str] = {} + + labels["traefik.enable"] = "true" + + labels[f"traefik.http.routers.{router_name}.rule"] = ( + f"Host(`{subdomain}`)" + ) + labels[f"traefik.http.routers.{router_name}.entrypoints"] = ( + self.entrypoint + ) + labels[f"traefik.http.routers.{router_name}.service"] = service_name + + if self.entrypoint == "websecure": + labels[f"traefik.http.routers.{router_name}.tls"] = "true" + labels[ + f"traefik.http.routers.{router_name}.tls.certresolver" + ] = "letsencrypt" + + labels[f"traefik.http.services.{service_name}.loadbalancer.server.port"] = ( + str(container_port) + ) + labels[f"traefik.http.services.{service_name}.loadbalancer.server.scheme"] = ( + "http" + ) + + middleware_name = f"tool-{instance_id[:8]}-sec" + labels[ + f"traefik.http.middlewares.{middleware_name}.headers.stsSeconds" + ] = "31536000" + labels[ + f"traefik.http.middlewares.{middleware_name}.headers.stsIncludeSubdomains" + ] = "true" + labels[ + f"traefik.http.middlewares.{middleware_name}.headers.forceStsHeader" + ] = "true" + labels[ + f"traefik.http.middlewares.{middleware_name}.headers.contentTypeNosniff" + ] = "true" + labels[ + f"traefik.http.middlewares.{middleware_name}.headers.browserXssFilter" + ] = "true" + labels[ + f"traefik.http.middlewares.{middleware_name}.headers.customFrameOptionsValue" + ] = "SAMEORIGIN" + + labels[f"traefik.http.routers.{router_name}.middlewares"] = middleware_name + + labels["traefik.docker.network"] = network_name + + return labels + + def generate_forward_auth_labels( + self, + instance_id: str, + auth_url: str, + ) -> dict[str, str]: + router_name = f"tool-{instance_id[:8]}" + middleware_name = f"tool-{instance_id[:8]}-auth" + + return { + f"traefik.http.middlewares.{middleware_name}.forwardauth.address": auth_url, + f"traefik.http.middlewares.{middleware_name}.forwardauth.trustForwardHeader": "true", + f"traefik.http.routers.{router_name}.middlewares": middleware_name, + } + + def generate_removal_labels( + self, + instance_id: str, + ) -> dict[str, str]: + router_name = f"tool-{instance_id[:8]}" + + return { + "traefik.enable": "false", + f"traefik.http.routers.{router_name}.rule": "", + } diff --git a/apps/api/app/tools/manifests/code-server.yml b/apps/api/app/tools/manifests/code-server.yml index 1270a48..0ffd1dc 100644 --- a/apps/api/app/tools/manifests/code-server.yml +++ b/apps/api/app/tools/manifests/code-server.yml @@ -3,6 +3,15 @@ name: code-server description: VS Code in the browser. version: "1.0.0" image: codercom/code-server:latest +runtime_command: + - "--bind-addr" + - "0.0.0.0:8080" + - "--auth" + - "none" + - "--disable-telemetry" + - "--disable-update-check" +runtime_entrypoint: [] +runtime_user: "coder" runtime_working_dir: /workspace ports: - container_port: 8080 @@ -19,11 +28,10 @@ config_mounts: 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 +env: + PASSWORD: "" + SUDO_PASSWORD: "" +secrets: [] health_check: type: http path: /healthz diff --git a/apps/api/tests/test_traefik.py b/apps/api/tests/test_traefik.py new file mode 100644 index 0000000..132133b --- /dev/null +++ b/apps/api/tests/test_traefik.py @@ -0,0 +1,197 @@ + +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" + )