import re from datetime import datetime from typing import Literal from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, field_validator ID_PATTERN = r"^[A-Za-z0-9._:-]+$" UUIDV7_PATTERN = r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" ID_RE = re.compile(ID_PATTERN) SENSITIVE_LABEL_WORDS = ( "token", "secret", "password", "credential", "authorization", "cookie", "api_key", "apikey", ) Status = Literal["ok", "warning", "critical", "unknown"] AgentStatus = Literal["alive", "stale", "dead"] IncidentState = Literal["open", "resolved"] Severity = Literal["warning", "critical", "unknown"] OutboxState = Literal["pending", "sending", "sent", "retry", "failed", "discarded"] OutboxEventType = Literal["firing", "resolved"] ErrorCode = Literal[ "unauthorized", "not_found", "validation", "payload_too_large", "rate_limited", "internal", "not_ready", ] class HealthResponse(BaseModel): status: Literal["ok"] = "ok" class ErrorBody(BaseModel): code: ErrorCode message: str request_id: str class ErrorResponse(BaseModel): error: ErrorBody class AcceptedResponse(BaseModel): accepted: bool = True class EventBatchAcceptedResponse(BaseModel): accepted: int = Field(ge=0) deduplicated: int = Field(ge=0) def _is_sensitive_label_key(key: str) -> bool: normalized = key.lower().replace("-", "_").replace(".", "_").replace(":", "_") return any(word in normalized for word in SENSITIVE_LABEL_WORDS) class AgentFeatures(BaseModel): model_config = ConfigDict(extra="forbid") push: bool metrics: bool class HeartbeatRequest(BaseModel): model_config = ConfigDict(extra="forbid") agent_id: str = Field(max_length=128, pattern=ID_PATTERN) observed_at: datetime hostname: str = Field(max_length=253) features: AgentFeatures labels: dict[str, str] = Field(default_factory=dict) @field_validator("labels") @classmethod def _check_labels(cls, v: dict[str, str]) -> dict[str, str]: if len(v) > 32: raise ValueError("too many labels (max 32)") for k, val in v.items(): if len(k) > 64 or not ID_RE.match(k): raise ValueError(f"invalid label key: {k!r}") if _is_sensitive_label_key(k): raise ValueError(f"label key is not allowed: {k!r}") if len(val) > 256: raise ValueError(f"label value too long for key {k!r}") return v class CheckResultEvent(BaseModel): model_config = ConfigDict(extra="forbid") event_id: str = Field(pattern=UUIDV7_PATTERN, min_length=36, max_length=36) check_id: str = Field(max_length=128, pattern=ID_PATTERN) observed_at: datetime status: Status exit_code: int duration_ms: int = Field(ge=0) output: str | None = Field(default=None, max_length=8192) output_truncated: bool = False notifications_enabled: bool = True incident_key: str | None = Field(default=None, max_length=256) @field_validator("output") @classmethod def _check_output_bytes(cls, v: str | None) -> str | None: if v is not None and len(v.encode("utf-8")) > 8192: raise ValueError("output exceeds 8192 UTF-8 bytes") return v class EventBatchRequest(BaseModel): model_config = ConfigDict(extra="forbid") agent_id: str = Field(max_length=128, pattern=ID_PATTERN) events: list[CheckResultEvent] = Field(min_length=1, max_length=200) class Agent(BaseModel): agent_id: str hostname: str status: AgentStatus last_seen_at: datetime features: AgentFeatures labels: dict[str, str] = Field(default_factory=dict) class CheckState(BaseModel): agent_id: str check_id: str status: Status last_observed_at: datetime exit_code: int incident_key: str class Incident(BaseModel): id: str incident_key: str state: IncidentState severity: Severity agent_id: str check_id: str opened_at: datetime resolved_at: datetime | None = None summary: str | None = None class StoredCheckResultEvent(CheckResultEvent): agent_id: str = Field(max_length=128, pattern=ID_PATTERN) received_at: datetime class NotificationOutboxItem(BaseModel): id: str notifier: str state: OutboxState incident_id: str event_type: OutboxEventType attempts: int = Field(ge=0) next_attempt_at: datetime | None = None last_error: str | None = None updated_at: datetime class PageEnvelope(BaseModel): next_cursor: str | None = None class AgentsPage(PageEnvelope): items: list[Agent] class ChecksPage(PageEnvelope): items: list[CheckState] class IncidentsPage(PageEnvelope): items: list[Incident] class EventsPage(PageEnvelope): items: list[StoredCheckResultEvent] class OutboxPage(PageEnvelope): items: list[NotificationOutboxItem] class UUIDStr(BaseModel): """Helper to validate generated UUIDs.""" value: UUID