Source code for conda_broker.models

"""Typed service models shared by providers, API users, and the supervisor."""

from __future__ import annotations

import re
import signal
from dataclasses import dataclass, field
from datetime import datetime, timezone
from math import isfinite
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from typing import Any

SERVICE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]*$")
ENV_NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")

START_POLICIES = {"manual", "enabled"}
RESTART_POLICIES = {"never", "on-failure", "always"}
HEALTH_CHECK_TYPES = {"process", "tcp", "http", "exec"}
ENDPOINT_PROTOCOLS = {"tcp", "http"}


[docs] class ServiceName(str): """A validated service or endpoint name.""" def __new__(cls, value: str) -> ServiceName: if not SERVICE_NAME_RE.fullmatch(value): raise ValueError( "Service names must contain only letters, numbers, '.', '_', and '-' " f"and must not be empty: {value!r}" ) return super().__new__(cls, value) @property def environment_slug(self) -> str: return ( "".join(char if char.isalnum() else "_" for char in self.upper()).strip("_") or "DEFAULT" )
[docs] @dataclass(frozen=True) class HealthCheck: """Health check definition for a service.""" type: str = "process" interval_s: float = 30.0 timeout_s: float = 5.0 start_period_s: float = 5.0 endpoint: str | None = None command: tuple[str, ...] = () host: str | None = None port: int | None = None url: str | None = None def __post_init__(self) -> None: object.__setattr__(self, "command", tuple(self.command)) if self.type not in HEALTH_CHECK_TYPES: raise ValueError(f"Unknown health check type: {self.type}") if not isfinite(self.interval_s) or self.interval_s <= 0: raise ValueError("Health check interval must be positive") if not isfinite(self.timeout_s) or self.timeout_s <= 0: raise ValueError("Health check timeout must be positive") if not isfinite(self.start_period_s) or self.start_period_s < 0: raise ValueError("Health check start period must not be negative") if self.endpoint is not None: ServiceName(self.endpoint) if ( self.type == "tcp" and not self.endpoint and (not self.host or not self.port) ): raise ValueError("TCP health checks require host and port") if self.type == "http" and not self.endpoint and not self.url: raise ValueError("HTTP health checks require url") if self.type == "exec" and not self.command: raise ValueError("Exec health checks require command") def to_dict(self) -> dict[str, Any]: return { "type": self.type, "interval_s": self.interval_s, "timeout_s": self.timeout_s, "start_period_s": self.start_period_s, "endpoint": self.endpoint, "command": list(self.command), "host": self.host, "port": self.port, "url": self.url, }
[docs] @dataclass(frozen=True) class EndpointSpec: """Network endpoint contract exposed by a service.""" name: str = "default" protocol: str = "tcp" host: str = "127.0.0.1" port: int | None = None path: str = "/" port_env: str | None = None url_env: str | None = None def __post_init__(self) -> None: object.__setattr__(self, "name", ServiceName(self.name)) if self.protocol not in ENDPOINT_PROTOCOLS: raise ValueError(f"Unknown endpoint protocol: {self.protocol}") if not self.host: raise ValueError("Endpoint host must not be empty") if self.port is not None and not 0 < self.port < 65536: raise ValueError("Endpoint port must be between 1 and 65535") if self.protocol == "http" and not self.path.startswith("/"): raise ValueError("HTTP endpoint paths must start with '/'") for env_name in (self.port_env, self.url_env): if env_name is not None and not ENV_NAME_RE.fullmatch(env_name): raise ValueError(f"Invalid environment variable name: {env_name!r}") @property def needs_port_allocation(self) -> bool: return self.port is None def to_dict(self) -> dict[str, Any]: return { "name": self.name, "protocol": self.protocol, "host": self.host, "port": self.port, "path": self.path, "port_env": self.port_env, "url_env": self.url_env, } def resolve(self, port: int | None = None) -> EndpointStatus: resolved_port = self.port if self.port is not None else port url = None if resolved_port is not None: url_host = ( f"[{self.host}]" if ":" in self.host and not self.host.startswith("[") else self.host ) url = ( f"http://{url_host}:{resolved_port}{self.path}" if self.protocol == "http" else f"{self.protocol}://{url_host}:{resolved_port}" ) return EndpointStatus( name=self.name, protocol=self.protocol, host=self.host, port=resolved_port, path=self.path, url=url, )
[docs] @dataclass(frozen=True) class EndpointStatus: """Resolved endpoint state reported for a service.""" name: str protocol: str host: str port: int | None = None path: str = "/" url: str | None = None
[docs] @classmethod def from_dict(cls, data: dict[str, Any]) -> EndpointStatus: """Build an endpoint status from a JSON payload.""" port_value = data.get("port") try: port = int(port_value) if port_value is not None else None except (TypeError, ValueError): port = None url_value = data.get("url") return cls( name=str(data.get("name", "default")), protocol=str(data.get("protocol", "tcp")), host=str(data.get("host", "127.0.0.1")), port=port, path=str(data.get("path", "/")), url=str(url_value) if url_value is not None else None, )
def to_dict(self) -> dict[str, Any]: return { "name": self.name, "protocol": self.protocol, "host": self.host, "port": self.port, "path": self.path, "url": self.url, }
[docs] @dataclass(frozen=True) class ProcessSpec: """Host process runtime definition.""" argv: tuple[str, ...] env: dict[str, str] = field(default_factory=dict) cwd: str | None = None stop_signal: str = "TERM" grace_period_s: float = 5.0 def __post_init__(self) -> None: object.__setattr__(self, "argv", tuple(self.argv)) environment = {str(key): str(value) for key, value in self.env.items()} for key, value in environment.items(): if not ENV_NAME_RE.fullmatch(key): raise ValueError(f"Invalid environment variable name: {key!r}") if "\0" in value: raise ValueError(f"Environment variable {key!r} contains a null byte") object.__setattr__(self, "env", environment) if not self.argv: raise ValueError("Process services require a non-empty argv") if not isfinite(self.grace_period_s) or self.grace_period_s <= 0: raise ValueError("Process grace period must be positive") _ = self.signal_number @property def signal_number(self) -> int: """Return the configured stop signal as an OS signal number.""" name = self.stop_signal.upper() candidates = (name,) if name.startswith("SIG") else (name, f"SIG{name}") for candidate in candidates: value = getattr(signal, candidate, None) if isinstance(value, int): return int(value) raise ValueError(f"Unknown process stop signal: {self.stop_signal}") def to_dict(self) -> dict[str, Any]: return { "argv": list(self.argv), "env": dict(self.env), "cwd": self.cwd, "stop_signal": self.stop_signal, "grace_period_s": self.grace_period_s, }
[docs] @dataclass(frozen=True) class CondaService: """A service definition returned by a conda-broker provider.""" name: str summary: str source: str process: ProcessSpec | None = None runtime: str = "process" start_policy: str = "manual" restart_policy: str = "on-failure" health_check: HealthCheck = field(default_factory=HealthCheck) endpoints: tuple[EndpointSpec, ...] = () dependencies: tuple[str, ...] = () env: dict[str, str] = field(default_factory=dict) cwd: str | None = None def __post_init__(self) -> None: object.__setattr__(self, "name", ServiceName(self.name)) object.__setattr__( self, "dependencies", tuple(ServiceName(name) for name in self.dependencies), ) object.__setattr__(self, "endpoints", tuple(self.endpoints)) environment = {str(key): str(value) for key, value in self.env.items()} for key, value in environment.items(): if not ENV_NAME_RE.fullmatch(key): raise ValueError(f"Invalid environment variable name: {key!r}") if "\0" in value: raise ValueError(f"Environment variable {key!r} contains a null byte") object.__setattr__(self, "env", environment) if not SERVICE_NAME_RE.fullmatch(self.runtime): raise ValueError(f"Invalid runtime name: {self.runtime!r}") if self.start_policy not in START_POLICIES: raise ValueError(f"Unknown start policy: {self.start_policy}") if self.restart_policy not in RESTART_POLICIES: raise ValueError(f"Unknown restart policy: {self.restart_policy}") endpoint_names = [endpoint.name for endpoint in self.endpoints] if len(endpoint_names) != len(set(endpoint_names)): raise ValueError("Service endpoints must have unique names") endpoint_slugs = [ServiceName(name).environment_slug for name in endpoint_names] if len(endpoint_slugs) != len(set(endpoint_slugs)): raise ValueError( "Service endpoint names must have unique environment variable names" ) automatic_env = {"CONDA_BROKER_SERVICE_NAME"} for slug in endpoint_slugs: prefix = f"CONDA_BROKER_ENDPOINT_{slug}" automatic_env.update( { f"{prefix}_PROTOCOL", f"{prefix}_HOST", f"{prefix}_PORT", f"{prefix}_URL", } ) custom_env = [ name for endpoint in self.endpoints for name in (endpoint.port_env, endpoint.url_env) if name is not None ] if len(custom_env) != len(set(custom_env)): raise ValueError("Service endpoint custom environment names must be unique") conflicts = sorted(set(custom_env) & automatic_env) if conflicts: raise ValueError( "Service endpoint custom environment names conflict with broker " f"variables: {', '.join(conflicts)}" ) if ( self.health_check.endpoint and self.health_check.endpoint not in endpoint_names ): raise ValueError( "Health check references unknown endpoint: " f"{self.health_check.endpoint}" ) if self.runtime == "process" and self.process is None: raise ValueError("'process' services require process") @property def enabled_by_default(self) -> bool: return self.start_policy == "enabled"
[docs] def merged_process(self) -> ProcessSpec: """Return the process spec with service-level env/cwd defaults applied.""" if self.process is None: raise ValueError(f"Service {self.name!r} has no process spec") env = dict(self.env) env.update(self.process.env) return ProcessSpec( argv=self.process.argv, env=env, cwd=self.process.cwd or self.cwd, stop_signal=self.process.stop_signal, grace_period_s=self.process.grace_period_s, )
[docs] def endpoint_statuses(self) -> dict[str, dict[str, Any]]: """Return unresolved endpoint status rows for stopped services.""" return { endpoint.name: endpoint.resolve().to_dict() for endpoint in self.endpoints }
def to_dict(self) -> dict[str, Any]: return { "name": self.name, "summary": self.summary, "source": self.source, "runtime": self.runtime, "start_policy": self.start_policy, "restart_policy": self.restart_policy, "health_check": self.health_check.to_dict(), "endpoints": [endpoint.to_dict() for endpoint in self.endpoints], "dependencies": list(self.dependencies), "env": dict(self.env), "cwd": self.cwd, "process": self.process.to_dict() if self.process else None, }
[docs] @dataclass(frozen=True) class ServiceStatus: """Observed state for a registered service.""" name: str summary: str source: str runtime: str enabled: bool state: str running: bool = False pid: int | None = None exit_code: int | None = None started_at: str | None = None restart_count: int = 0 health: str = "unknown" ready: bool = False endpoints: dict[str, dict[str, Any]] = field(default_factory=dict) @staticmethod def parse_int(value: object) -> int | None: if value is None: return None try: return int(str(value)) except (TypeError, ValueError): return None
[docs] @classmethod def from_dict(cls, data: dict[str, Any]) -> ServiceStatus: """Build a service status from a JSON payload.""" endpoints = data.get("endpoints") endpoint_data = { str(name): dict(endpoint) for name, endpoint in ( endpoints.items() if isinstance(endpoints, dict) else () ) if isinstance(endpoint, dict) } return cls( name=str(data.get("name", "")), summary=str(data.get("summary", "")), source=str(data.get("source", "")), runtime=str(data.get("runtime", "")), enabled=bool(data.get("enabled", False)), state=str(data.get("state", "unknown")), running=bool(data.get("running", False)), pid=cls.parse_int(data.get("pid")), exit_code=cls.parse_int(data.get("exit_code")), started_at=( str(data["started_at"]) if data.get("started_at") is not None else None ), restart_count=int(data.get("restart_count") or 0), health=str(data.get("health", "unknown")), ready=bool(data.get("ready", False)), endpoints=endpoint_data, )
[docs] def endpoint(self, name: str = "default") -> EndpointStatus | None: """Return one typed endpoint status by name.""" data = self.endpoints.get(name) if data is None: return None return EndpointStatus.from_dict(data)
def to_dict(self) -> dict[str, Any]: return { "name": self.name, "summary": self.summary, "source": self.source, "runtime": self.runtime, "enabled": self.enabled, "state": self.state, "running": self.running, "pid": self.pid, "exit_code": self.exit_code, "started_at": self.started_at, "restart_count": self.restart_count, "health": self.health, "ready": self.ready, "endpoints": dict(self.endpoints), }
[docs] @dataclass(frozen=True) class ServiceEvent: """Append-only event record.""" type: str service: str | None = None message: str = "" data: dict[str, Any] = field(default_factory=dict) timestamp: str = field( default_factory=lambda: datetime.now(timezone.utc).isoformat() )
[docs] @classmethod def from_dict(cls, data: dict[str, Any]) -> ServiceEvent: """Build a service event from a JSON payload.""" event_data = data.get("data") values: dict[str, Any] = { "type": str(data.get("type", "plugin.event")), "service": ( str(data["service"]) if data.get("service") is not None else None ), "message": str(data.get("message", "")), "data": dict(event_data) if isinstance(event_data, dict) else {}, } if data.get("timestamp") is not None: values["timestamp"] = str(data["timestamp"]) return cls(**values)
def to_dict(self) -> dict[str, Any]: return { "timestamp": self.timestamp, "type": self.type, "service": self.service, "message": self.message, "data": dict(self.data), }