"""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