Add web auth, infinite-scroll, agent admission and review fixes across agent/server/web

This commit is contained in:
Stanislav Rossovskii
2026-05-28 23:45:12 +04:00
parent 37b1a1d6d6
commit 1026e9ebbe
75 changed files with 3021 additions and 916 deletions

View File

@@ -7,8 +7,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from .. import metrics
from ..agent_auth import AgentCredential, get_agent_credential
from ..db import get_session
from ..logging_config import get_logger
from ..models import Event
from ..schemas import (
ID_PATTERN,
@@ -22,6 +24,8 @@ from ..timeutil import to_utc
router = APIRouter()
log = get_logger("monlet.events")
def _encode_cursor(received_at: datetime, event_id) -> str:
raw = f"{to_utc(received_at).isoformat()}|{event_id}".encode()
@@ -52,12 +56,24 @@ async def events(
) -> EventBatchAcceptedResponse:
agent = await accepted_agent_for_key(session, credential)
if agent is None:
await session.commit()
return EventBatchAcceptedResponse(accepted=0, deduplicated=0)
raise HTTPException(status_code=403, detail="agent not accepted")
accepted = 0
deduplicated = 0
for ev in body.events:
ok = await ingest_event(session, agent.agent_id, ev)
try:
async with session.begin_nested():
ok = await ingest_event(session, agent.agent_id, ev)
except HTTPException as exc:
if exc.status_code != 400:
raise
log.warning(
"event_rejected",
check_id=ev.check_id,
agent_id=agent.agent_id,
code=exc.detail,
)
metrics.events_total.labels(result="rejected").inc()
continue
if ok:
accepted += 1
else:

View File

@@ -46,11 +46,12 @@ def install_error_handlers(app: FastAPI) -> None:
@app.exception_handler(StarletteHTTPException)
async def _http(request: Request, exc: StarletteHTTPException):
code = (
"agent_blocked"
if exc.status_code == 403 and exc.detail == "agent blocked"
else _STATUS_TO_CODE.get(exc.status_code, "internal")
)
code: ErrorCode = _STATUS_TO_CODE.get(exc.status_code, "internal")
if exc.status_code == 403:
if exc.detail == "agent blocked":
code = "agent_blocked"
elif exc.detail == "agent not accepted":
code = "agent_not_accepted"
msg = exc.detail if isinstance(exc.detail, str) else code
return error_response(request, exc.status_code, code, msg)

View File

@@ -1,4 +1,4 @@
from prometheus_client import CollectorRegistry, Counter, Gauge, Histogram, generate_latest
from prometheus_client import CollectorRegistry, Counter, Gauge, generate_latest
from prometheus_client.exposition import CONTENT_TYPE_LATEST
REGISTRY = CollectorRegistry()
@@ -42,12 +42,6 @@ notifications_total = Counter(
["notifier", "result"],
registry=REGISTRY,
)
request_duration = Histogram(
"monlet_server_request_duration_seconds",
"Request duration seconds.",
["path"],
registry=REGISTRY,
)
def render() -> tuple[bytes, str]:

View File

@@ -1,7 +1,6 @@
import re
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
@@ -39,6 +38,7 @@ ErrorCode = Literal[
"internal",
"not_ready",
"agent_blocked",
"agent_not_accepted",
]
@@ -233,12 +233,6 @@ 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

View File

@@ -53,13 +53,7 @@ async def _blacklist_row(session: AsyncSession, cred: AgentCredential) -> AgentB
return res.scalar_one_or_none()
async def reject_if_blocked(
session: AsyncSession,
cred: AgentCredential,
agent_id: str,
hostname: str,
observed: datetime | None = None,
) -> None:
async def reject_if_blocked(session: AsyncSession, cred: AgentCredential) -> None:
row = await _blacklist_row(session, cred)
if row is None:
return
@@ -108,7 +102,7 @@ async def upsert_agent_heartbeat(
session: AsyncSession, hb: HeartbeatRequest, cred: AgentCredential
) -> str:
observed = to_utc(hb.observed_at)
await reject_if_blocked(session, cred, hb.agent_id, hb.hostname, observed)
await reject_if_blocked(session, cred)
features = hb.features.model_dump()
res = await session.execute(select(Agent).where(Agent.accepted_key_hash == cred.key_hash))
@@ -330,7 +324,6 @@ async def ingest_event(
incident_key=incident_key,
)
await session.execute(stmt)
metrics.events_total.labels(result="accepted").inc()
else:
metrics.events_total.labels(result="unchanged").inc()
@@ -366,6 +359,9 @@ async def ingest_event(
return False
transition, inc = await _apply_incident(session, agent_id, ev, incident_key)
# Count accepted only after _apply_incident: an incident_key collision raises
# 400 and the savepoint rollback discards the Event insert above.
metrics.events_total.labels(result="accepted").inc()
if transition and inc is not None and ev.notifications_enabled:
event_type = "resolved" if ev.status == "ok" else "firing"
await _enqueue_outbox(

View File

@@ -208,9 +208,16 @@ async def tick(
await session.commit()
if not rows:
return 0
await asyncio.gather(
*(_process_row(sm, registry, r, settings.notifier_max_attempts) for r in rows)
)
# Bound concurrent DB sessions: each _process_row opens its own session, so an
# unbounded gather over a large batch can exhaust the connection pool.
sem = asyncio.Semaphore(settings.notifier_concurrency)
async def _guarded(row: dict) -> None:
async with sem:
await _process_row(sm, registry, row, settings.notifier_max_attempts)
await asyncio.gather(*(_guarded(r) for r in rows))
return len(rows)

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
import httpx
def classify_response(status: int) -> tuple[bool, bool]:
"""Return (ok, permanent_failure)."""
@@ -10,9 +8,3 @@ def classify_response(status: int) -> tuple[bool, bool]:
if 400 <= status < 500 and status not in (408, 425, 429):
return False, True
return False, False
def classify_exception(exc: Exception) -> tuple[bool, bool]:
if isinstance(exc, httpx.TimeoutException | httpx.NetworkError):
return False, False
return False, False

View File

@@ -1,4 +1,4 @@
from pydantic import field_validator
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from .timeutil import validate_time_zone
@@ -40,6 +40,7 @@ class Settings(BaseSettings):
notifier_batch_size: int = 20
notifier_max_attempts: int = 8
notifier_lease_sec: int = 300
notifier_concurrency: int = Field(default=4, ge=1)
notifier_http_timeout_sec: float = 10.0
notification_time_zone: str = "UTC"