Files
monlet/server/monlet_server/schemas.py
2026-05-28 14:19:27 +04:00

251 lines
6.1 KiB
Python

import re
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
from .timeutil import require_utc_datetime
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"]
AgentAdmissionState = Literal["pending", "accepted"]
IncidentState = Literal["open", "resolved"]
Severity = Literal["warning", "critical", "unknown"]
OutboxState = Literal["pending", "sending", "sent", "retry", "failed", "discarded"]
OutboxEventType = Literal["firing", "resolved"]
ErrorCode = Literal[
"unauthorized",
"forbidden",
"conflict",
"not_found",
"validation",
"payload_too_large",
"rate_limited",
"internal",
"not_ready",
"agent_blocked",
]
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("observed_at")
@classmethod
def _observed_at_must_be_utc(cls, v: datetime) -> datetime:
return require_utc_datetime(v)
@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("observed_at")
@classmethod
def _observed_at_must_be_utc(cls, v: datetime) -> datetime:
return require_utc_datetime(v)
@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
admission_state: AgentAdmissionState
rotation_pending: bool = False
key_fingerprint: str | None = None
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
summary: str | None = None
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
prev_cursor: str | None = None
class AgentsPage(PageEnvelope):
items: list[Agent]
class AgentBlacklistItem(BaseModel):
id: str
key_fingerprint: str
agent_id: str
hostname: str
created_at: datetime
last_seen_at: datetime
class AgentBlacklistPage(PageEnvelope):
items: list[AgentBlacklistItem]
class ActionCountResponse(BaseModel):
count: int = Field(ge=0)
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
class SystemSummaryResponse(BaseModel):
agents_online: int
agents_offline: int
checks_ok: int
checks_warning: int
checks_critical: int
checks_unknown: int
open_incidents: int
generated_at: datetime