Implement Monlet MVP stack and UI updates
This commit is contained in:
201
server/monlet_server/schemas.py
Normal file
201
server/monlet_server/schemas.py
Normal file
@@ -0,0 +1,201 @@
|
||||
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
|
||||
Reference in New Issue
Block a user