Add web auth, infinite-scroll, agent admission and review fixes across agent/server/web
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -22,14 +22,14 @@ async def test_unknown_heartbeat_creates_pending_agent(app_client, auth_headers,
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_events_are_dropped_until_accept(app_client, auth_headers, session):
|
||||
async def test_pending_events_are_rejected_until_accept(app_client, auth_headers, session):
|
||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
||||
ev = make_event(status="critical", exit_code=2)
|
||||
r = await app_client.post(
|
||||
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
||||
)
|
||||
assert r.status_code == 202
|
||||
assert r.json() == {"accepted": 0, "deduplicated": 0}
|
||||
assert r.status_code == 403
|
||||
assert r.json()["error"]["code"] == "agent_not_accepted"
|
||||
n = (await session.execute(select(func.count()).select_from(Check))).scalar_one()
|
||||
assert n == 0
|
||||
|
||||
|
||||
@@ -23,13 +23,13 @@ async def test_event_accepted_and_state(app_client, ui_auth_headers, session):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_before_heartbeat_is_dropped(app_client, auth_headers, session):
|
||||
async def test_event_before_heartbeat_is_rejected(app_client, auth_headers, session):
|
||||
ev = make_event(status="warning", exit_code=1)
|
||||
r = await app_client.post(
|
||||
"/api/v1/events", json={"agent_id": "agent-replay", "events": [ev]}, headers=auth_headers
|
||||
)
|
||||
assert r.status_code == 202
|
||||
assert r.json() == {"accepted": 0, "deduplicated": 0}
|
||||
assert r.status_code == 403
|
||||
assert r.json()["error"]["code"] == "agent_not_accepted"
|
||||
n = (await session.execute(select(func.count()).select_from(Agent))).scalar_one()
|
||||
assert n == 0
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ async def test_incident_key_ownership_rejected(app_client, ui_auth_headers):
|
||||
r2 = await app_client.post(
|
||||
"/api/v1/events", json={"agent_id": "agent-2", "events": [ev2]}, headers=agent_2_headers
|
||||
)
|
||||
assert r2.status_code == 400
|
||||
assert r2.json()["error"]["code"] == "validation"
|
||||
assert r2.status_code == 202
|
||||
assert r2.json() == {"accepted": 0, "deduplicated": 0}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user