251 lines
6.1 KiB
Python
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
|