Harden production workflows and agent admission

This commit is contained in:
Stanislav Rossovskii
2026-05-28 14:19:27 +04:00
parent a2e88b4e76
commit 37b1a1d6d6
109 changed files with 4927 additions and 894 deletions

View File

@@ -3,11 +3,14 @@ MONLET_DATABASE_URL=postgresql+asyncpg://monlet:monlet@localhost:5432/monlet
MONLET_LOG_LEVEL=INFO
MONLET_BODY_LIMIT_BYTES=1048576
MONLET_OUTPUT_MAX_BYTES=8192
MONLET_STALE_AFTER_SEC=90
MONLET_DEAD_AFTER_SEC=300
MONLET_DETECTOR_TICK_SEC=15
MONLET_STALE_AFTER_SEC=20
MONLET_DEAD_AFTER_SEC=30
MONLET_DETECTOR_TICK_SEC=5
MONLET_ENABLE_DETECTOR=true
MONLET_EVENTS_RETENTION_MONTHS=36
MONLET_MAX_PENDING_AGENTS=10000
MONLET_PENDING_AGENT_TTL_DAYS=7
MONLET_PENDING_AGENT_PRUNE_BATCH_SIZE=1000
MONLET_OUTBOX_RETENTION_MAX_ROWS=50000
MONLET_ENABLE_NOTIFIER_WORKER=true

View File

@@ -20,8 +20,17 @@ RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
FROM python:3.14-slim
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1
# PH-015: non-root runtime user. UID/GID are stable so bind-mounts can be
# pre-chowned by operators if they need writable host paths.
RUN groupadd --system --gid 1000 monlet \
&& useradd --system --uid 1000 --gid monlet --home-dir /app --shell /usr/sbin/nologin monlet
WORKDIR /app
COPY --from=builder /app /app
RUN chown -R monlet:monlet /app
USER monlet:monlet
EXPOSE 8000
STOPSIGNAL SIGTERM
CMD ["uvicorn", "monlet_server.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -30,18 +30,21 @@ UV_PROJECT_ENVIRONMENT=.venv UV_CACHE_DIR=../.cache/uv uv run fastapi dev monlet
| Var | Default | Purpose |
|---|---|---|
| `MONLET_AUTH_TOKEN` | required | Shared Bearer token (ADR-0007); `changeme` is rejected |
| `MONLET_AUTH_TOKEN` | required | UI/operator Bearer token. Space/comma-separated list; any accepted. `changeme` is rejected. |
| `MONLET_DATABASE_URL` | `postgresql+asyncpg://monlet:monlet@127.0.0.1:5432/monlet` | Async DSN |
| `MONLET_LOG_LEVEL` | `INFO` | Log level |
| `MONLET_BODY_LIMIT_BYTES` | `1048576` | Request body limit (ADR-0005) |
| `MONLET_OUTPUT_MAX_BYTES` | `8192` | Per-event output limit |
| `MONLET_STALE_AFTER_SEC` | `90` | Stale threshold |
| `MONLET_DEAD_AFTER_SEC` | `300` | Dead threshold |
| `MONLET_DETECTOR_TICK_SEC` | `15` | Detector tick |
| `MONLET_STALE_AFTER_SEC` | `20` | Stale threshold |
| `MONLET_DEAD_AFTER_SEC` | `30` | Dead threshold |
| `MONLET_DETECTOR_TICK_SEC` | `5` | Detector tick |
| `MONLET_ENABLE_DETECTOR` | `true` | Toggle detector task |
| `MONLET_EVENTS_RETENTION_MONTHS` | `36` | Months of `events` partitions to keep; `0` disables drop |
| `MONLET_EVENTS_FUTURE_PARTITIONS` | `3` | Future month partitions to pre-create |
| `MONLET_EVENT_DEDUP_RETENTION_DAYS` | `30` | Idempotency window for `event_id` |
| `MONLET_MAX_PENDING_AGENTS` | `10000` | Maximum pending agent keys before heartbeat rejects with 429 |
| `MONLET_PENDING_AGENT_TTL_DAYS` | `7` | Pending key cleanup TTL; `0` disables pruning |
| `MONLET_PENDING_AGENT_PRUNE_BATCH_SIZE` | `1000` | Pending cleanup rows per detector tick |
| `MONLET_PARTITION_MAINTENANCE_INTERVAL_SEC` | `3600` | Partition create/drop worker interval |
| `MONLET_OUTBOX_RETENTION_MAX_ROWS` | `50000` | Maximum retained terminal outbox rows; `0` disables pruning |
| `MONLET_ENABLE_NOTIFIER_WORKER` | `true` | Toggle outbox worker |
@@ -79,4 +82,7 @@ docker build -t monlet-server .
## Endpoints
All routes under `/api/v1` (per `api/openapi.yaml`). `/metrics` and `/api/v1/health`, `/api/v1/ready` are open; everything else requires `Authorization: Bearer <token>`.
All routes under `/api/v1` (per `api/openapi.yaml`). `/metrics` and `/api/v1/health`, `/api/v1/ready` are open.
- UI/operator endpoints require `Authorization: Bearer <MONLET_AUTH_TOKEN>`.
- Agent ingestion endpoints accept the per-agent key generated and stored by the agent. A new key creates a pending agent; events are ignored until an operator accepts it.

View File

@@ -20,18 +20,53 @@ def upgrade() -> None:
op.execute(
"""
CREATE TABLE agents (
agent_id TEXT PRIMARY KEY,
hostname TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('alive','stale','dead')) DEFAULT 'alive',
last_seen_at TIMESTAMPTZ NOT NULL,
features JSONB NOT NULL DEFAULT jsonb_build_object('push', false, 'metrics', false),
labels JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
agent_id TEXT PRIMARY KEY,
hostname TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('alive','stale','dead')) DEFAULT 'alive',
last_seen_at TIMESTAMPTZ NOT NULL,
features JSONB NOT NULL DEFAULT jsonb_build_object('push', false, 'metrics', false),
labels JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
accepted_key_hash TEXT,
accepted_key_fingerprint TEXT,
accepted_at TIMESTAMPTZ,
pending_key_hash TEXT,
pending_key_fingerprint TEXT,
pending_hostname TEXT,
pending_seen_at TIMESTAMPTZ,
pending_features JSONB,
pending_labels JSONB,
admission_rank INTEGER NOT NULL GENERATED ALWAYS AS (
CASE WHEN pending_key_hash IS NOT NULL THEN 0 ELSE 1 END
) STORED
);
"""
)
op.execute("CREATE INDEX agents_status_idx ON agents (status);")
op.execute("CREATE INDEX agents_last_seen_idx ON agents (last_seen_at);")
op.execute("CREATE INDEX agents_admission_rank_idx ON agents (admission_rank, agent_id);")
op.execute("CREATE UNIQUE INDEX agents_hostname_idx ON agents (hostname);")
op.execute(
"CREATE UNIQUE INDEX agents_accepted_key_hash_idx ON agents (accepted_key_hash) WHERE accepted_key_hash IS NOT NULL;"
)
op.execute(
"CREATE UNIQUE INDEX agents_pending_key_hash_idx ON agents (pending_key_hash) WHERE pending_key_hash IS NOT NULL;"
)
op.execute(
"""
CREATE TABLE agent_blacklist (
key_hash TEXT PRIMARY KEY,
key_fingerprint TEXT NOT NULL,
agent_id TEXT NOT NULL,
hostname TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL
);
"""
)
op.execute("CREATE INDEX agent_blacklist_fingerprint_idx ON agent_blacklist (key_fingerprint);")
op.execute("CREATE INDEX agent_blacklist_last_seen_idx ON agent_blacklist (last_seen_at);")
op.execute(
"""
@@ -76,6 +111,18 @@ def upgrade() -> None:
)
op.execute("CREATE INDEX events_observed_at_idx ON events (observed_at DESC);")
op.execute("CREATE INDEX events_received_at_idx ON events (received_at DESC);")
# PH-010: pagination uses ORDER BY (received_at DESC, event_id DESC). Compound
# indexes match the unfiltered and per-agent/per-check filtered queries so the
# planner does not fall back to seq-scan + sort on hot partitions.
op.execute(
"CREATE INDEX events_received_event_idx ON events (received_at DESC, event_id DESC);"
)
op.execute(
"CREATE INDEX events_agent_received_event_idx ON events (agent_id, received_at DESC, event_id DESC);"
)
op.execute(
"CREATE INDEX events_check_received_event_idx ON events (check_id, received_at DESC, event_id DESC);"
)
# Global idempotency table: server enforces event_id uniqueness here, not on the
# partitioned events table. Pruned by retention TTL (event_dedup_retention_days).
@@ -91,6 +138,10 @@ def upgrade() -> None:
"CREATE INDEX event_ingest_dedup_received_at_idx ON event_ingest_dedup (received_at);"
)
# PH-review: status_rank is a STORED generated column so the default UI sort
# (status_rank, opened_at, id) maps to a trivial B-tree index. Keep the
# CASE expression in sync with `_STATUS_RANK` in
# server/monlet_server/api/incidents.py.
op.execute(
"""
CREATE TABLE incidents (
@@ -103,14 +154,26 @@ def upgrade() -> None:
opened_at TIMESTAMPTZ NOT NULL,
resolved_at TIMESTAMPTZ,
summary TEXT,
last_event_id UUID NOT NULL
last_event_id UUID NOT NULL,
status_rank INTEGER NOT NULL GENERATED ALWAYS AS (
CASE
WHEN state = 'open' AND severity = 'critical' THEN 5
WHEN state = 'open' AND severity = 'warning' THEN 4
WHEN state = 'open' AND severity = 'unknown' THEN 3
WHEN state = 'resolved' AND severity = 'critical' THEN 2
WHEN state = 'resolved' AND severity = 'warning' THEN 1
WHEN state = 'resolved' AND severity = 'unknown' THEN 0
END
) STORED
);
"""
)
op.execute(
"CREATE UNIQUE INDEX incidents_open_key_idx ON incidents (incident_key) WHERE state = 'open';"
)
op.execute("CREATE INDEX incidents_state_opened_idx ON incidents (state, opened_at DESC);")
op.execute(
"CREATE INDEX incidents_status_rank_idx ON incidents (status_rank DESC, opened_at DESC, id DESC);"
)
op.execute(
"""
@@ -132,6 +195,13 @@ def upgrade() -> None:
op.execute(
"CREATE INDEX outbox_pending_idx ON notification_outbox (state, next_attempt_at) WHERE state IN ('pending','retry');"
)
# PH-review: UI lists outbox with cursor pagination ordered by created_at/updated_at.
op.execute(
"CREATE INDEX outbox_created_at_idx ON notification_outbox (created_at DESC, id DESC);"
)
op.execute(
"CREATE INDEX outbox_updated_at_idx ON notification_outbox (updated_at DESC, id DESC);"
)
def downgrade() -> None:
@@ -140,4 +210,5 @@ def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS event_ingest_dedup CASCADE;")
op.execute("DROP TABLE IF EXISTS events CASCADE;")
op.execute("DROP TABLE IF EXISTS checks CASCADE;")
op.execute("DROP TABLE IF EXISTS agent_blacklist CASCADE;")
op.execute("DROP TABLE IF EXISTS agents CASCADE;")

View File

@@ -0,0 +1,30 @@
from dataclasses import dataclass
from hashlib import sha256
from fastapi import Request
@dataclass(frozen=True)
class AgentCredential:
key_hash: str
key_fingerprint: str
def hash_agent_key(token: str) -> str:
return sha256(token.encode("utf-8")).hexdigest()
def fingerprint_from_hash(key_hash: str) -> str:
return key_hash[:16]
def credential_from_token(token: str) -> AgentCredential:
key_hash = hash_agent_key(token)
return AgentCredential(key_hash=key_hash, key_fingerprint=fingerprint_from_hash(key_hash))
def get_agent_credential(request: Request) -> AgentCredential:
cred = getattr(request.state, "agent_credential", None)
if not isinstance(cred, AgentCredential):
raise RuntimeError("agent credential missing")
return cred

View File

@@ -1,49 +1,341 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy import and_, delete, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..cursors import decode, encode
from ..db import get_session
from ..models import Agent as AgentModel
from ..schemas import Agent, AgentsPage
from ..models import AgentBlacklist, Event, Incident, NotificationOutbox
from ..schemas import (
ActionCountResponse,
Agent,
AgentBlacklistItem,
AgentBlacklistPage,
AgentsPage,
)
from ..timeutil import to_utc
router = APIRouter()
def _is_pending(a: AgentModel) -> bool:
return a.pending_key_hash is not None
def _to_schema(a: AgentModel) -> Agent:
pending = _is_pending(a)
hostname = a.pending_hostname if pending and a.pending_hostname is not None else a.hostname
last_seen_at = (
a.pending_seen_at if pending and a.pending_seen_at is not None else a.last_seen_at
)
features = a.pending_features if pending and a.pending_features is not None else a.features
labels = a.pending_labels if pending and a.pending_labels is not None else a.labels
return Agent(
agent_id=a.agent_id,
hostname=a.hostname,
hostname=hostname,
status=a.status,
last_seen_at=to_utc(a.last_seen_at),
features=a.features or {"push": False, "metrics": False},
labels=a.labels or {},
admission_state="pending" if pending or a.accepted_key_hash is None else "accepted",
rotation_pending=pending and a.accepted_key_hash is not None,
key_fingerprint=(a.pending_key_fingerprint if pending else a.accepted_key_fingerprint),
last_seen_at=to_utc(last_seen_at),
features=features or {"push": False, "metrics": False},
labels=labels or {},
)
def _blacklist_schema(row: AgentBlacklist) -> AgentBlacklistItem:
return AgentBlacklistItem(
id=row.key_hash,
key_fingerprint=row.key_fingerprint,
agent_id=row.agent_id,
hostname=row.hostname,
created_at=to_utc(row.created_at),
last_seen_at=to_utc(row.last_seen_at),
)
async def _get_agent(session: AsyncSession, agent_id: str) -> AgentModel:
res = await session.execute(select(AgentModel).where(AgentModel.agent_id == agent_id))
a = res.scalar_one_or_none()
if a is None:
raise HTTPException(status_code=404, detail="agent not found")
return a
def _clear_pending(a: AgentModel) -> None:
a.pending_key_hash = None
a.pending_key_fingerprint = None
a.pending_hostname = None
a.pending_seen_at = None
a.pending_features = None
a.pending_labels = None
def _accept_pending(a: AgentModel) -> bool:
if not _is_pending(a):
return False
now = datetime.now(UTC)
a.accepted_key_hash = a.pending_key_hash
a.accepted_key_fingerprint = a.pending_key_fingerprint
a.accepted_at = now
a.hostname = a.pending_hostname or a.hostname
a.last_seen_at = a.pending_seen_at or a.last_seen_at
if a.pending_features is not None:
a.features = a.pending_features
if a.pending_labels is not None:
a.labels = a.pending_labels
a.status = "alive"
_clear_pending(a)
return True
async def _block_pending(session: AsyncSession, a: AgentModel) -> bool:
if not _is_pending(a):
return False
now = datetime.now(UTC)
stmt = pg_insert(AgentBlacklist).values(
key_hash=a.pending_key_hash,
key_fingerprint=a.pending_key_fingerprint,
agent_id=a.agent_id,
hostname=a.pending_hostname or a.hostname,
last_seen_at=a.pending_seen_at or now,
)
stmt = stmt.on_conflict_do_update(
index_elements=[AgentBlacklist.key_hash],
set_={
"agent_id": stmt.excluded.agent_id,
"hostname": stmt.excluded.hostname,
"last_seen_at": stmt.excluded.last_seen_at,
},
)
await session.execute(stmt)
_clear_pending(a)
if a.accepted_key_hash is None:
await _delete_agent_data(session, a.agent_id)
return True
async def _delete_agent_data(session: AsyncSession, agent_id: str) -> None:
incident_ids = select(Incident.id).where(Incident.agent_id == agent_id)
await session.execute(
delete(NotificationOutbox).where(NotificationOutbox.incident_id.in_(incident_ids))
)
await session.execute(delete(Incident).where(Incident.agent_id == agent_id))
await session.execute(delete(Event).where(Event.agent_id == agent_id))
await session.execute(delete(AgentModel).where(AgentModel.agent_id == agent_id))
async def _remove_pending_or_agent(session: AsyncSession, a: AgentModel) -> None:
if _is_pending(a) and a.accepted_key_hash is not None:
_clear_pending(a)
return
await _delete_agent_data(session, a.agent_id)
def _agent_rank(a: AgentModel) -> int:
return a.admission_rank
def _decode_agent_cursor(cursor: str) -> tuple[int, str]:
rank_raw, agent_id = decode(cursor, 2)
try:
rank = int(rank_raw)
except ValueError as exc:
raise HTTPException(status_code=400, detail="invalid cursor") from exc
if rank not in (0, 1):
raise HTTPException(status_code=400, detail="invalid cursor")
return rank, agent_id
@router.get("/agents", response_model=AgentsPage)
async def list_agents(
cursor: str | None = Query(default=None, max_length=512),
back: bool = Query(default=False),
limit: int = Query(default=100, ge=1, le=500),
session: AsyncSession = Depends(get_session),
) -> AgentsPage:
stmt = select(AgentModel).order_by(AgentModel.agent_id)
if back:
stmt = select(AgentModel).order_by(
AgentModel.admission_rank.desc(), AgentModel.agent_id.desc()
)
else:
stmt = select(AgentModel).order_by(
AgentModel.admission_rank.asc(), AgentModel.agent_id.asc()
)
if cursor is not None:
(last_id,) = decode(cursor, 1)
stmt = stmt.where(AgentModel.agent_id > last_id)
last_rank, last_id = _decode_agent_cursor(cursor)
if back:
stmt = stmt.where(
or_(
AgentModel.admission_rank < last_rank,
and_(
AgentModel.admission_rank == last_rank,
AgentModel.agent_id < last_id,
),
)
)
else:
stmt = stmt.where(
or_(
AgentModel.admission_rank > last_rank,
and_(
AgentModel.admission_rank == last_rank,
AgentModel.agent_id > last_id,
),
)
)
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
next_cursor = None
if len(rows) > limit:
rows = rows[:limit]
next_cursor = encode(rows[-1].agent_id)
return AgentsPage(items=[_to_schema(a) for a in rows], next_cursor=next_cursor)
has_more = len(rows) > limit
rows = rows[:limit]
if back:
rows = list(reversed(rows))
next_cursor: str | None = None
prev_cursor: str | None = None
if rows:
if back:
next_cursor = encode(_agent_rank(rows[-1]), rows[-1].agent_id)
if has_more:
prev_cursor = encode(_agent_rank(rows[0]), rows[0].agent_id)
else:
if has_more:
next_cursor = encode(_agent_rank(rows[-1]), rows[-1].agent_id)
if cursor is not None:
prev_cursor = encode(_agent_rank(rows[0]), rows[0].agent_id)
return AgentsPage(
items=[_to_schema(a) for a in rows],
next_cursor=next_cursor,
prev_cursor=prev_cursor,
)
@router.get("/agents/admission/blacklist", response_model=AgentBlacklistPage)
async def list_agent_blacklist(
cursor: str | None = Query(default=None, max_length=512),
limit: int = Query(default=100, ge=1, le=500),
session: AsyncSession = Depends(get_session),
) -> AgentBlacklistPage:
stmt = select(AgentBlacklist).order_by(AgentBlacklist.key_fingerprint, AgentBlacklist.key_hash)
if cursor is not None:
last_fingerprint, last_hash = decode(cursor, 2)
stmt = stmt.where(
or_(
AgentBlacklist.key_fingerprint > last_fingerprint,
and_(
AgentBlacklist.key_fingerprint == last_fingerprint,
AgentBlacklist.key_hash > last_hash,
),
)
)
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
has_more = len(rows) > limit
rows = rows[:limit]
return AgentBlacklistPage(
items=[_blacklist_schema(r) for r in rows],
next_cursor=encode(rows[-1].key_fingerprint, rows[-1].key_hash)
if rows and has_more
else None,
prev_cursor=None,
)
@router.delete("/agents/admission/blacklist", response_model=ActionCountResponse)
async def clear_agent_blacklist(
session: AsyncSession = Depends(get_session),
) -> ActionCountResponse:
res = await session.execute(delete(AgentBlacklist))
await session.commit()
return ActionCountResponse(count=res.rowcount or 0)
@router.delete("/agents/admission/blacklist/{key_id}", response_model=ActionCountResponse)
async def delete_agent_blacklist_item(
key_id: str,
session: AsyncSession = Depends(get_session),
) -> ActionCountResponse:
res = await session.execute(delete(AgentBlacklist).where(AgentBlacklist.key_hash == key_id))
await session.commit()
return ActionCountResponse(count=res.rowcount or 0)
@router.post("/agents/admission/accept-all", response_model=ActionCountResponse)
async def accept_all_pending_agents(
include_rotation: bool = Query(default=False),
session: AsyncSession = Depends(get_session),
) -> ActionCountResponse:
stmt = select(AgentModel).where(AgentModel.pending_key_hash.is_not(None))
if not include_rotation:
stmt = stmt.where(AgentModel.accepted_key_hash.is_(None))
rows = (await session.execute(stmt)).scalars().all()
count = sum(1 for a in rows if _accept_pending(a))
await session.commit()
return ActionCountResponse(count=count)
@router.post("/agents/admission/block-all", response_model=ActionCountResponse)
async def block_all_pending_agents(
session: AsyncSession = Depends(get_session),
) -> ActionCountResponse:
rows = (
(await session.execute(select(AgentModel).where(AgentModel.pending_key_hash.is_not(None))))
.scalars()
.all()
)
count = 0
for a in rows:
if await _block_pending(session, a):
count += 1
await session.commit()
return ActionCountResponse(count=count)
@router.post("/agents/admission/remove-all", response_model=ActionCountResponse)
async def remove_all_pending_agents(
session: AsyncSession = Depends(get_session),
) -> ActionCountResponse:
rows = (
(await session.execute(select(AgentModel).where(AgentModel.pending_key_hash.is_not(None))))
.scalars()
.all()
)
for agent in rows:
await _remove_pending_or_agent(session, agent)
await session.commit()
return ActionCountResponse(count=len(rows))
@router.post("/agents/{agent_id}/accept", response_model=ActionCountResponse)
async def accept_agent(
agent_id: str, session: AsyncSession = Depends(get_session)
) -> ActionCountResponse:
agent = await _get_agent(session, agent_id)
count = 1 if _accept_pending(agent) else 0
await session.commit()
return ActionCountResponse(count=count)
@router.post("/agents/{agent_id}/block", response_model=ActionCountResponse)
async def block_agent(
agent_id: str, session: AsyncSession = Depends(get_session)
) -> ActionCountResponse:
agent = await _get_agent(session, agent_id)
count = 1 if await _block_pending(session, agent) else 0
await session.commit()
return ActionCountResponse(count=count)
@router.delete("/agents/{agent_id}", response_model=ActionCountResponse)
async def delete_agent(
agent_id: str, session: AsyncSession = Depends(get_session)
) -> ActionCountResponse:
agent = await _get_agent(session, agent_id)
await _remove_pending_or_agent(session, agent)
await session.commit()
return ActionCountResponse(count=1)
@router.get("/agents/{agent_id}", response_model=Agent)
async def get_agent(agent_id: str, session: AsyncSession = Depends(get_session)) -> Agent:
res = await session.execute(select(AgentModel).where(AgentModel.agent_id == agent_id))
a = res.scalar_one_or_none()
if a is None:
raise HTTPException(status_code=404, detail="agent not found")
return _to_schema(a)
return _to_schema(await _get_agent(session, agent_id))

View File

@@ -15,25 +15,50 @@ router = APIRouter()
async def list_checks(
agent_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN),
cursor: str | None = Query(default=None, max_length=512),
back: bool = Query(default=False),
limit: int = Query(default=100, ge=1, le=500),
session: AsyncSession = Depends(get_session),
) -> ChecksPage:
stmt = select(Check).order_by(Check.agent_id, Check.check_id)
# PH-review: bidirectional cursor. Natural sort is ASC (agent_id, check_id).
if back:
stmt = select(Check).order_by(Check.agent_id.desc(), Check.check_id.desc())
else:
stmt = select(Check).order_by(Check.agent_id, Check.check_id)
if agent_id is not None:
stmt = stmt.where(Check.agent_id == agent_id)
if cursor is not None:
last_agent, last_check = decode(cursor, 2)
stmt = stmt.where(
or_(
Check.agent_id > last_agent,
and_(Check.agent_id == last_agent, Check.check_id > last_check),
if back:
stmt = stmt.where(
or_(
Check.agent_id < last_agent,
and_(Check.agent_id == last_agent, Check.check_id < last_check),
)
)
else:
stmt = stmt.where(
or_(
Check.agent_id > last_agent,
and_(Check.agent_id == last_agent, Check.check_id > last_check),
)
)
)
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
next_cursor = None
if len(rows) > limit:
rows = rows[:limit]
next_cursor = encode(rows[-1].agent_id, rows[-1].check_id)
has_more = len(rows) > limit
rows = rows[:limit]
if back:
rows = list(reversed(rows))
next_cursor: str | None = None
prev_cursor: str | None = None
if rows:
if back:
next_cursor = encode(rows[-1].agent_id, rows[-1].check_id)
if has_more:
prev_cursor = encode(rows[0].agent_id, rows[0].check_id)
else:
if has_more:
next_cursor = encode(rows[-1].agent_id, rows[-1].check_id)
if cursor is not None:
prev_cursor = encode(rows[0].agent_id, rows[0].check_id)
items = [
CheckState(
agent_id=r.agent_id,
@@ -46,4 +71,4 @@ async def list_checks(
)
for r in rows
]
return ChecksPage(items=items, next_cursor=next_cursor)
return ChecksPage(items=items, next_cursor=next_cursor, prev_cursor=prev_cursor)

View File

@@ -1,11 +1,13 @@
import base64
import binascii
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..agent_auth import AgentCredential, get_agent_credential
from ..db import get_session
from ..models import Event
from ..schemas import (
@@ -15,7 +17,7 @@ from ..schemas import (
EventsPage,
StoredCheckResultEvent,
)
from ..services.ingestion import ingest_event
from ..services.ingestion import accepted_agent_for_key, ingest_event
from ..timeutil import to_utc
router = APIRouter()
@@ -26,12 +28,14 @@ def _encode_cursor(received_at: datetime, event_id) -> str:
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def _decode_cursor(cursor: str) -> tuple[datetime, str]:
def _decode_cursor(cursor: str) -> tuple[datetime, UUID]:
pad = "=" * (-len(cursor) % 4)
try:
raw = base64.urlsafe_b64decode(cursor + pad).decode()
ts_str, eid = raw.split("|", 1)
return to_utc(datetime.fromisoformat(ts_str)), eid
# PH-010: decode event_id to UUID before comparing to the UUID column
# to avoid silent type-coercion behavior across DB drivers.
return to_utc(datetime.fromisoformat(ts_str)), UUID(eid)
except (binascii.Error, ValueError) as exc:
raise HTTPException(status_code=400, detail="invalid cursor") from exc
@@ -42,12 +46,18 @@ def _decode_cursor(cursor: str) -> tuple[datetime, str]:
status_code=status.HTTP_202_ACCEPTED,
)
async def events(
body: EventBatchRequest, session: AsyncSession = Depends(get_session)
body: EventBatchRequest,
credential: AgentCredential = Depends(get_agent_credential),
session: AsyncSession = Depends(get_session),
) -> EventBatchAcceptedResponse:
agent = await accepted_agent_for_key(session, credential)
if agent is None:
await session.commit()
return EventBatchAcceptedResponse(accepted=0, deduplicated=0)
accepted = 0
deduplicated = 0
for ev in body.events:
ok = await ingest_event(session, body.agent_id, ev)
ok = await ingest_event(session, agent.agent_id, ev)
if ok:
accepted += 1
else:
@@ -61,28 +71,55 @@ async def query_events(
agent_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN),
check_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN),
cursor: str | None = Query(default=None, max_length=512),
back: bool = Query(default=False),
limit: int = Query(default=100, ge=1, le=500),
session: AsyncSession = Depends(get_session),
) -> EventsPage:
stmt = select(Event).order_by(Event.received_at.desc(), Event.event_id.desc())
# PH-review: bidirectional cursor. The natural sort is DESC on
# (received_at, event_id); `back=True` reverses the query so we can return
# the previous page without an unbounded cursor stack in the URL.
if back:
stmt = select(Event).order_by(Event.received_at.asc(), Event.event_id.asc())
else:
stmt = select(Event).order_by(Event.received_at.desc(), Event.event_id.desc())
if agent_id is not None:
stmt = stmt.where(Event.agent_id == agent_id)
if check_id is not None:
stmt = stmt.where(Event.check_id == check_id)
if cursor is not None:
ts, eid = _decode_cursor(cursor)
stmt = stmt.where(
or_(
Event.received_at < ts,
and_(Event.received_at == ts, Event.event_id < eid),
if back:
stmt = stmt.where(
or_(
Event.received_at > ts,
and_(Event.received_at == ts, Event.event_id > eid),
)
)
else:
stmt = stmt.where(
or_(
Event.received_at < ts,
and_(Event.received_at == ts, Event.event_id < eid),
)
)
)
stmt = stmt.limit(limit + 1)
rows = (await session.execute(stmt)).scalars().all()
next_cursor = None
if len(rows) > limit:
rows = rows[:limit]
next_cursor = _encode_cursor(rows[-1].received_at, rows[-1].event_id)
has_more = len(rows) > limit
rows = rows[:limit]
if back:
rows = list(reversed(rows))
next_cursor: str | None = None
prev_cursor: str | None = None
if rows:
if back:
next_cursor = _encode_cursor(rows[-1].received_at, rows[-1].event_id)
if has_more:
prev_cursor = _encode_cursor(rows[0].received_at, rows[0].event_id)
else:
if has_more:
next_cursor = _encode_cursor(rows[-1].received_at, rows[-1].event_id)
if cursor is not None:
prev_cursor = _encode_cursor(rows[0].received_at, rows[0].event_id)
items = [
StoredCheckResultEvent(
event_id=str(r.event_id),
@@ -100,4 +137,4 @@ async def query_events(
)
for r in rows
]
return EventsPage(items=items, next_cursor=next_cursor)
return EventsPage(items=items, next_cursor=next_cursor, prev_cursor=prev_cursor)

View File

@@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from ..agent_auth import AgentCredential, get_agent_credential
from ..db import get_session
from ..schemas import AcceptedResponse, HeartbeatRequest
from ..services.ingestion import upsert_agent_heartbeat
@@ -14,8 +15,10 @@ router = APIRouter()
status_code=status.HTTP_202_ACCEPTED,
)
async def heartbeat(
body: HeartbeatRequest, session: AsyncSession = Depends(get_session)
body: HeartbeatRequest,
credential: AgentCredential = Depends(get_agent_credential),
session: AsyncSession = Depends(get_session),
) -> AcceptedResponse:
await upsert_agent_heartbeat(session, body)
await upsert_agent_heartbeat(session, body, credential)
await session.commit()
return AcceptedResponse(accepted=True)

View File

@@ -1,3 +1,5 @@
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -5,11 +7,29 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ..cursors import decode, decode_datetime, encode
from ..db import get_session
from ..models import Incident as IncidentModel
from ..schemas import Incident, IncidentsPage
from ..schemas import ID_PATTERN, Incident, IncidentsPage
from ..timeutil import to_utc
router = APIRouter()
_SORT_COLUMNS = {
"opened_at": IncidentModel.opened_at,
"resolved_at": IncidentModel.resolved_at,
}
SortField = Literal["status", "opened_at", "resolved_at"]
# PH-review: the rank is materialized as a STORED generated column
# `incidents.status_rank` (see models.py / 0001_baseline.py). This map mirrors
# the SQL CASE for Python-side cursor encoding only.
_STATUS_RANK: dict[tuple[str, str], int] = {
("open", "critical"): 5,
("open", "warning"): 4,
("open", "unknown"): 3,
("resolved", "critical"): 2,
("resolved", "warning"): 1,
("resolved", "unknown"): 0,
}
def _to_schema(i: IncidentModel) -> Incident:
return Incident(
@@ -25,30 +45,105 @@ def _to_schema(i: IncidentModel) -> Incident:
)
def _encode_incident_cursor(i: IncidentModel, sort: SortField) -> str:
if sort == "status":
return encode(i.status_rank, to_utc(i.opened_at).isoformat(), i.id)
return encode(to_utc(getattr(i, sort)).isoformat(), i.id)
@router.get("/incidents", response_model=IncidentsPage)
async def list_incidents(
state: str | None = Query(default=None),
state: Literal["open", "resolved"] | None = Query(default=None),
severity: Literal["warning", "critical", "unknown"] | None = Query(default=None),
agent_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN),
check_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN),
sort: SortField = Query(default="status"),
direction: Literal["asc", "desc"] = Query(default="desc"),
cursor: str | None = Query(default=None, max_length=512),
back: bool = Query(default=False),
limit: int = Query(default=100, ge=1, le=500),
session: AsyncSession = Depends(get_session),
) -> IncidentsPage:
if state is not None and state not in ("open", "resolved"):
raise HTTPException(status_code=400, detail="invalid state")
stmt = select(IncidentModel).order_by(IncidentModel.opened_at.desc(), IncidentModel.id.desc())
# PH-001 finding-4: sort=resolved_at must skip open incidents — their
# resolved_at is NULL and would otherwise break to_utc() on the cursor.
if sort == "resolved_at" and state is None:
state = "resolved"
if sort == "resolved_at" and state == "open":
raise HTTPException(status_code=400, detail="sort=resolved_at requires state=resolved")
# PH-review: `back=True` flips the effective sort to walk to the previous
# page. We reverse the rows before returning so the response order matches
# the canonical (user-requested) direction.
effective_desc = (direction == "desc") ^ back
if sort == "status":
rank_col = IncidentModel.status_rank
rank_order = rank_col.desc() if effective_desc else rank_col.asc()
opened_order = (
IncidentModel.opened_at.desc() if effective_desc else IncidentModel.opened_at.asc()
)
id_order = IncidentModel.id.desc() if effective_desc else IncidentModel.id.asc()
stmt = select(IncidentModel).order_by(rank_order, opened_order, id_order)
else:
col = _SORT_COLUMNS[sort]
order_col = col.desc() if effective_desc else col.asc()
tiebreak = IncidentModel.id.desc() if effective_desc else IncidentModel.id.asc()
stmt = select(IncidentModel).order_by(order_col, tiebreak)
if sort == "resolved_at":
stmt = stmt.where(IncidentModel.resolved_at.is_not(None))
if state is not None:
stmt = stmt.where(IncidentModel.state == state)
if severity is not None:
stmt = stmt.where(IncidentModel.severity == severity)
if agent_id is not None:
stmt = stmt.where(IncidentModel.agent_id == agent_id)
if check_id is not None:
stmt = stmt.where(IncidentModel.check_id == check_id)
if cursor is not None:
ts_str, last_id = decode(cursor, 2)
ts = decode_datetime(ts_str)
stmt = stmt.where(
or_(
IncidentModel.opened_at < ts,
and_(IncidentModel.opened_at == ts, IncidentModel.id < last_id),
if sort == "status":
rank_str, ts_str, last_id = decode(cursor, 3)
try:
rank = int(rank_str)
except ValueError as exc:
raise HTTPException(status_code=400, detail="invalid cursor") from exc
ts = decode_datetime(ts_str)
rank_col = IncidentModel.status_rank
rank_cmp = rank_col < rank if effective_desc else rank_col > rank
opened_cmp = (
IncidentModel.opened_at < ts if effective_desc else IncidentModel.opened_at > ts
)
)
id_cmp = IncidentModel.id < last_id if effective_desc else IncidentModel.id > last_id
stmt = stmt.where(
or_(
rank_cmp,
and_(rank_col == rank, opened_cmp),
and_(rank_col == rank, IncidentModel.opened_at == ts, id_cmp),
)
)
else:
col = _SORT_COLUMNS[sort]
ts_str, last_id = decode(cursor, 2)
ts = decode_datetime(ts_str)
cmp = col < ts if effective_desc else col > ts
tie = IncidentModel.id < last_id if effective_desc else IncidentModel.id > last_id
stmt = stmt.where(or_(cmp, and_(col == ts, tie)))
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
next_cursor = None
if len(rows) > limit:
rows = rows[:limit]
next_cursor = encode(to_utc(rows[-1].opened_at).isoformat(), rows[-1].id)
return IncidentsPage(items=[_to_schema(r) for r in rows], next_cursor=next_cursor)
has_more = len(rows) > limit
rows = rows[:limit]
if back:
rows = list(reversed(rows))
next_cursor: str | None = None
prev_cursor: str | None = None
if rows:
if back:
next_cursor = _encode_incident_cursor(rows[-1], sort)
if has_more:
prev_cursor = _encode_incident_cursor(rows[0], sort)
else:
if has_more:
next_cursor = _encode_incident_cursor(rows[-1], sort)
if cursor is not None:
prev_cursor = _encode_incident_cursor(rows[0], sort)
return IncidentsPage(
items=[_to_schema(r) for r in rows],
next_cursor=next_cursor,
prev_cursor=prev_cursor,
)

View File

@@ -1,3 +1,5 @@
from typing import Literal
from fastapi import APIRouter, Depends, Query
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,36 +12,73 @@ from ..timeutil import to_utc
router = APIRouter()
# PH-002/PH-011/PH-018: explicit sort + filter contracts.
OutboxState = Literal["pending", "sending", "sent", "retry", "failed", "discarded"]
OutboxEventType = Literal["firing", "resolved"]
OutboxNotifier = Literal["debug", "telegram", "webhook", "alertmanager"]
_SORT_COLUMNS = {
"created_at": NotificationOutbox.created_at,
"updated_at": NotificationOutbox.updated_at,
}
SortField = Literal["created_at", "updated_at"]
def _encode_outbox_cursor(row: NotificationOutbox, sort: SortField) -> str:
return encode(to_utc(getattr(row, sort)).isoformat(), row.id)
@router.get("/notifiers/outbox", response_model=OutboxPage)
async def list_outbox(
state: str | None = Query(default=None),
state: OutboxState | None = Query(default=None),
notifier: OutboxNotifier | None = Query(default=None),
event_type: OutboxEventType | None = Query(default=None),
sort: SortField = Query(default="updated_at"),
direction: Literal["asc", "desc"] = Query(default="desc"),
cursor: str | None = Query(default=None, max_length=512),
back: bool = Query(default=False),
limit: int = Query(default=100, ge=1, le=500),
session: AsyncSession = Depends(get_session),
) -> OutboxPage:
stmt = select(NotificationOutbox).order_by(
NotificationOutbox.created_at.desc(), NotificationOutbox.id.desc()
)
col = _SORT_COLUMNS[sort]
# PH-review: bidirectional cursor — for `back=True` we run the query in the
# opposite direction relative to the requested sort and reverse the result
# client-side. This avoids the unbounded cursor_stack in the URL.
effective_desc = (direction == "desc") ^ back
order_col = col.desc() if effective_desc else col.asc()
tiebreak = NotificationOutbox.id.desc() if effective_desc else NotificationOutbox.id.asc()
stmt = select(NotificationOutbox).order_by(order_col, tiebreak)
if state is not None:
stmt = stmt.where(NotificationOutbox.state == state)
if notifier is not None:
stmt = stmt.where(NotificationOutbox.notifier == notifier)
if event_type is not None:
stmt = stmt.where(NotificationOutbox.event_type == event_type)
if cursor is not None:
ts_str, last_id = decode(cursor, 2)
ts = decode_datetime(ts_str)
stmt = stmt.where(
or_(
NotificationOutbox.created_at < ts,
and_(
NotificationOutbox.created_at == ts,
NotificationOutbox.id < last_id,
),
)
)
cmp = col < ts if effective_desc else col > ts
tie = NotificationOutbox.id < last_id if effective_desc else NotificationOutbox.id > last_id
stmt = stmt.where(or_(cmp, and_(col == ts, tie)))
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
next_cursor = None
if len(rows) > limit:
rows = rows[:limit]
next_cursor = encode(to_utc(rows[-1].created_at).isoformat(), rows[-1].id)
has_more = len(rows) > limit
rows = rows[:limit]
if back:
rows = list(reversed(rows))
next_cursor: str | None = None
prev_cursor: str | None = None
if rows:
if back:
# Came from a forward page → there is always a next page (the one
# we just came from). `has_more` here means there are still earlier
# pages remaining.
next_cursor = _encode_outbox_cursor(rows[-1], sort)
if has_more:
prev_cursor = _encode_outbox_cursor(rows[0], sort)
else:
if has_more:
next_cursor = _encode_outbox_cursor(rows[-1], sort)
if cursor is not None:
prev_cursor = _encode_outbox_cursor(rows[0], sort)
return OutboxPage(
items=[
NotificationOutboxItem(
@@ -56,4 +95,5 @@ async def list_outbox(
for r in rows
],
next_cursor=next_cursor,
prev_cursor=prev_cursor,
)

View File

@@ -0,0 +1,43 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db import get_session
from ..models import Agent, Check, Incident
from ..schemas import SystemSummaryResponse
router = APIRouter()
@router.get("/system-summary", response_model=SystemSummaryResponse)
async def system_summary(session: AsyncSession = Depends(get_session)) -> SystemSummaryResponse:
agents_rows = (
await session.execute(
select(Agent.status, func.count())
.where(Agent.accepted_key_hash.is_not(None))
.group_by(Agent.status)
)
).all()
checks_rows = (
await session.execute(select(Check.status, func.count()).group_by(Check.status))
).all()
open_incidents = (
await session.execute(
select(func.count()).select_from(Incident).where(Incident.state == "open")
)
).scalar_one()
agents = {status: count for status, count in agents_rows}
checks = {status: count for status, count in checks_rows}
return SystemSummaryResponse(
agents_online=agents.get("alive", 0),
agents_offline=agents.get("stale", 0) + agents.get("dead", 0),
checks_ok=checks.get("ok", 0),
checks_warning=checks.get("warning", 0),
checks_critical=checks.get("critical", 0),
checks_unknown=checks.get("unknown", 0),
open_incidents=open_incidents,
generated_at=datetime.now(UTC),
)

View File

@@ -17,7 +17,17 @@ def get_engine() -> AsyncEngine:
global _engine, _sessionmaker
if _engine is None:
s = get_settings()
_engine = create_async_engine(s.database_url, pool_pre_ping=True, future=True)
# PH-016: explicit pool sizing. pool_pre_ping protects against stale
# connections after PG restarts; pool_recycle bounds connection age.
_engine = create_async_engine(
s.database_url,
pool_pre_ping=True,
pool_size=s.db_pool_size,
max_overflow=s.db_max_overflow,
pool_timeout=s.db_pool_timeout_sec,
pool_recycle=s.db_pool_recycle_sec,
future=True,
)
_sessionmaker = async_sessionmaker(_engine, expire_on_commit=False)
return _engine

View File

@@ -28,6 +28,8 @@ def error_response(
_STATUS_TO_CODE: dict[int, ErrorCode] = {
400: "validation",
401: "unauthorized",
403: "forbidden",
409: "conflict",
404: "not_found",
413: "payload_too_large",
429: "rate_limited",
@@ -44,7 +46,11 @@ def install_error_handlers(app: FastAPI) -> None:
@app.exception_handler(StarletteHTTPException)
async def _http(request: Request, exc: StarletteHTTPException):
code = _STATUS_TO_CODE.get(exc.status_code, "internal")
code = (
"agent_blocked"
if exc.status_code == 403 and exc.detail == "agent blocked"
else _STATUS_TO_CODE.get(exc.status_code, "internal")
)
msg = exc.detail if isinstance(exc.detail, str) else code
return error_response(request, exc.status_code, code, msg)

View File

@@ -5,7 +5,7 @@ from fastapi import FastAPI
from fastapi.responses import Response
from . import metrics
from .api import agents, checks, events, health, heartbeat, incidents, outbox
from .api import agents, checks, events, health, heartbeat, incidents, outbox, system_summary
from .db import dispose_engine, get_engine, get_sessionmaker
from .errors import install_error_handlers
from .logging_config import configure_logging, get_logger
@@ -86,6 +86,7 @@ def create_app() -> FastAPI:
app.include_router(checks.router, prefix="/api/v1")
app.include_router(incidents.router, prefix="/api/v1")
app.include_router(outbox.router, prefix="/api/v1")
app.include_router(system_summary.router, prefix="/api/v1")
@app.get("/metrics")
async def metrics_endpoint() -> Response:

View File

@@ -1,22 +1,42 @@
import hmac
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from ..agent_auth import credential_from_token
from ..errors import error_response
from ..settings import get_settings
NO_AUTH_PATHS = {"/api/v1/health", "/api/v1/ready", "/metrics"}
AGENT_PATHS = {"/api/v1/heartbeat", "/api/v1/events"}
def _match_token(presented: str, accepted: list[str]) -> bool:
matched = False
# Constant-time across all accepted tokens to avoid timing oracle.
for t in accepted:
if hmac.compare_digest(presented, t):
matched = True
return matched
class BearerAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if request.url.path in NO_AUTH_PATHS:
path = request.url.path
if path in NO_AUTH_PATHS:
return await call_next(request)
expected = get_settings().auth_token
header = request.headers.get("authorization")
if not header or not header.lower().startswith("bearer "):
return error_response(request, 401, "unauthorized", "authentication required")
token = header.split(" ", 1)[1].strip()
if token != expected:
return error_response(request, 401, "unauthorized", "authentication required")
if path in AGENT_PATHS:
if len(token) < 32 or len(token) > 512:
return error_response(request, 401, "unauthorized", "authentication required")
request.state.agent_credential = credential_from_token(token)
else:
if not _match_token(token, get_settings().auth_tokens):
return error_response(request, 401, "unauthorized", "authentication required")
return await call_next(request)

View File

@@ -5,6 +5,7 @@ from sqlalchemy import (
JSON,
Boolean,
CheckConstraint,
Computed,
DateTime,
ForeignKey,
Index,
@@ -51,11 +52,57 @@ class Agent(Base):
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
accepted_key_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
accepted_key_fingerprint: Mapped[str | None] = mapped_column(Text, nullable=True)
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
pending_key_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
pending_key_fingerprint: Mapped[str | None] = mapped_column(Text, nullable=True)
pending_hostname: Mapped[str | None] = mapped_column(Text, nullable=True)
pending_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
pending_features: Mapped[dict | None] = mapped_column(_jsonb(), nullable=True)
pending_labels: Mapped[dict | None] = mapped_column(_jsonb(), nullable=True)
admission_rank: Mapped[int] = mapped_column(
Integer,
Computed("CASE WHEN pending_key_hash IS NOT NULL THEN 0 ELSE 1 END", persisted=True),
nullable=False,
)
__table_args__ = (
CheckConstraint("status IN ('alive','stale','dead')", name="agents_status_chk"),
Index("agents_status_idx", "status"),
Index("agents_last_seen_idx", "last_seen_at"),
Index("agents_admission_rank_idx", "admission_rank", "agent_id"),
Index("agents_hostname_idx", "hostname", unique=True),
Index(
"agents_accepted_key_hash_idx",
"accepted_key_hash",
unique=True,
postgresql_where=text("accepted_key_hash IS NOT NULL"),
),
Index(
"agents_pending_key_hash_idx",
"pending_key_hash",
unique=True,
postgresql_where=text("pending_key_hash IS NOT NULL"),
),
)
class AgentBlacklist(Base):
__tablename__ = "agent_blacklist"
key_hash: Mapped[str] = mapped_column(Text, primary_key=True)
key_fingerprint: Mapped[str] = mapped_column(Text, nullable=False)
agent_id: Mapped[str] = mapped_column(Text, nullable=False)
hostname: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
__table_args__ = (
Index("agent_blacklist_fingerprint_idx", "key_fingerprint"),
Index("agent_blacklist_last_seen_idx", "last_seen_at"),
)
@@ -117,6 +164,19 @@ class Event(Base):
),
Index("events_observed_at_idx", text("observed_at DESC")),
Index("events_received_at_idx", text("received_at DESC")),
Index("events_received_event_idx", text("received_at DESC"), text("event_id DESC")),
Index(
"events_agent_received_event_idx",
"agent_id",
text("received_at DESC"),
text("event_id DESC"),
),
Index(
"events_check_received_event_idx",
"check_id",
text("received_at DESC"),
text("event_id DESC"),
),
)
@@ -144,6 +204,25 @@ class Incident(Base):
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
last_event_id: Mapped[UUID] = mapped_column(_uuid(), nullable=False)
# PH-review: stored generated column so the default UI sort
# (status_rank, opened_at, id) is served by a plain B-tree index without
# planner mismatches between query-time and index-time CASE expressions.
# Keep aligned with `_STATUS_RANK` in api/incidents.py.
status_rank: Mapped[int] = mapped_column(
Integer,
Computed(
"CASE "
"WHEN state = 'open' AND severity = 'critical' THEN 5 "
"WHEN state = 'open' AND severity = 'warning' THEN 4 "
"WHEN state = 'open' AND severity = 'unknown' THEN 3 "
"WHEN state = 'resolved' AND severity = 'critical' THEN 2 "
"WHEN state = 'resolved' AND severity = 'warning' THEN 1 "
"WHEN state = 'resolved' AND severity = 'unknown' THEN 0 "
"END",
persisted=True,
),
nullable=False,
)
__table_args__ = (
CheckConstraint("state IN ('open','resolved')", name="incidents_state_chk"),
@@ -156,7 +235,12 @@ class Incident(Base):
unique=True,
postgresql_where=text("state = 'open'"),
),
Index("incidents_state_opened_idx", "state", text("opened_at DESC")),
Index(
"incidents_status_rank_idx",
text("status_rank DESC"),
text("opened_at DESC"),
text("id DESC"),
),
)
@@ -193,4 +277,6 @@ class NotificationOutbox(Base):
"next_attempt_at",
postgresql_where=text("state IN ('pending','retry')"),
),
Index("outbox_created_at_idx", text("created_at DESC"), text("id DESC")),
Index("outbox_updated_at_idx", text("updated_at DESC"), text("id DESC")),
)

View File

@@ -23,18 +23,22 @@ SENSITIVE_LABEL_WORDS = (
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",
]
@@ -140,6 +144,9 @@ 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)
@@ -186,12 +193,30 @@ class NotificationOutboxItem(BaseModel):
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]
@@ -212,3 +237,14 @@ 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

View File

@@ -202,14 +202,16 @@ async def _apply_liveness(session, agent: Agent, next_status: str, now: datetime
summary=summary,
)
)
else:
elif changed:
# PH-009: update liveness check row only on status transitions to avoid
# O(N) per-tick rewrites of every agent_liveness row. last_observed_at
# then reflects the meaningful transition timestamp, not detector ticks.
cur.status = liveness_status
cur.exit_code = exit_code
cur.last_event_id = event_id
cur.incident_key = incident_key
cur.last_observed_at = now
cur.summary = summary
if changed:
cur.status = liveness_status
cur.exit_code = exit_code
cur.last_event_id = event_id
cur.incident_key = incident_key
if next_status in ("stale", "dead"):
await _open_liveness_incident(session, agent, next_status, now, event_id)
@@ -219,6 +221,34 @@ async def _apply_liveness(session, agent: Agent, next_status: str, now: datetime
async def _prune_history(session) -> None:
s = get_settings()
if s.pending_agent_ttl_days > 0:
cutoff = datetime.now(UTC) - timedelta(days=s.pending_agent_ttl_days)
rows = (
(
await session.execute(
select(Agent)
.where(
Agent.pending_key_hash.is_not(None),
Agent.pending_seen_at < cutoff,
)
.order_by(Agent.pending_seen_at)
.limit(max(s.pending_agent_prune_batch_size, 1))
)
)
.scalars()
.all()
)
for agent in rows:
if agent.accepted_key_hash is None:
await session.delete(agent)
else:
agent.pending_key_hash = None
agent.pending_key_fingerprint = None
agent.pending_hostname = None
agent.pending_seen_at = None
agent.pending_features = None
agent.pending_labels = None
if s.event_dedup_retention_days > 0:
cutoff = datetime.now(UTC) - timedelta(days=s.event_dedup_retention_days)
batch = max(s.event_dedup_prune_batch_size, 1)
@@ -233,17 +263,29 @@ async def _prune_history(session) -> None:
await session.execute(delete(EventIngestDedup).where(EventIngestDedup.event_id.in_(sub)))
if s.outbox_retention_max_rows > 0:
# PH-012: keep the N newest terminal rows. Delete in a bounded batch to
# avoid one huge DELETE under failure storms. Active rows ('pending',
# 'sending', 'retry') are never eligible — terminal-only filter
# protects them.
keep_outbox = (
select(NotificationOutbox.id)
.where(NotificationOutbox.state.in_(OUTBOX_TERMINAL_STATES))
.order_by(NotificationOutbox.created_at.desc(), NotificationOutbox.id.desc())
.limit(s.outbox_retention_max_rows)
)
await session.execute(
delete(NotificationOutbox)
prune_ids = (
select(NotificationOutbox.id)
.where(NotificationOutbox.state.in_(OUTBOX_TERMINAL_STATES))
.where(NotificationOutbox.id.not_in(keep_outbox))
.limit(max(s.outbox_prune_batch_size, 1))
.scalar_subquery()
)
await session.execute(
delete(NotificationOutbox).where(NotificationOutbox.id.in_(prune_ids))
)
DETECTOR_BATCH_SIZE = 500
async def _tick(sm: async_sessionmaker) -> None:
@@ -252,23 +294,50 @@ async def _tick(sm: async_sessionmaker) -> None:
stale_cut = now - timedelta(seconds=s.stale_after_sec)
dead_cut = now - timedelta(seconds=s.dead_after_sec)
async with sm() as session:
agents = (await session.execute(select(Agent))).scalars().all()
for agent in agents:
if agent.last_seen_at <= dead_cut:
next_status = "dead"
elif agent.last_seen_at <= stale_cut:
next_status = "stale"
else:
next_status = "alive"
if agent.status != next_status:
agent.status = next_status
await _apply_liveness(session, agent, next_status, now)
# PH-009: keyset-paginate agents by agent_id to bound per-tick memory
# and per-batch transaction work. Each batch is committed separately so
# large inventories do not produce one huge transaction.
# Race trade-off: a new agent inserted with agent_id < last_seen_id
# mid-tick is skipped this tick and processed next tick. That delay is
# bounded by detector_tick_sec (5s by default) and is acceptable
# liveness detection has a stale_after_sec / dead_after_sec horizon
# measured in tens of seconds, so a one-tick lag is invisible.
last_seen_id: str | None = None
while True:
q = (
select(Agent)
.where(Agent.accepted_key_hash.is_not(None))
.order_by(Agent.agent_id)
.limit(DETECTOR_BATCH_SIZE)
)
if last_seen_id is not None:
q = q.where(Agent.agent_id > last_seen_id)
agents = (await session.execute(q)).scalars().all()
if not agents:
break
for agent in agents:
if agent.last_seen_at <= dead_cut:
next_status = "dead"
elif agent.last_seen_at <= stale_cut:
next_status = "stale"
else:
next_status = "alive"
if agent.status != next_status:
agent.status = next_status
await _apply_liveness(session, agent, next_status, now)
last_seen_id = agents[-1].agent_id
await session.commit()
if len(agents) < DETECTOR_BATCH_SIZE:
break
await _prune_history(session)
await session.commit()
for status in ("alive", "stale", "dead"):
r = await session.execute(
select(func.count()).select_from(Agent).where(Agent.status == status)
select(func.count())
.select_from(Agent)
.where(Agent.accepted_key_hash.is_not(None), Agent.status == status)
)
metrics.agents_gauge.labels(status=status).set(r.scalar_one())
r = await session.execute(

View File

@@ -2,13 +2,22 @@ from datetime import UTC, datetime
from uuid import UUID, uuid4
from fastapi import HTTPException
from sqlalchemy import select, text
from sqlalchemy import func, select, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from .. import metrics
from ..agent_auth import AgentCredential
from ..logging_config import get_logger
from ..models import Agent, Check, Event, EventIngestDedup, Incident, NotificationOutbox
from ..models import (
Agent,
AgentBlacklist,
Check,
Event,
EventIngestDedup,
Incident,
NotificationOutbox,
)
from ..redaction import redact
from ..schemas import CheckResultEvent, HeartbeatRequest
from ..settings import get_settings
@@ -37,28 +46,143 @@ def _incident_key(agent_id: str, ev: CheckResultEvent) -> str:
return key
async def upsert_agent_heartbeat(session: AsyncSession, hb: HeartbeatRequest) -> None:
async def _blacklist_row(session: AsyncSession, cred: AgentCredential) -> AgentBlacklist | None:
res = await session.execute(
select(AgentBlacklist).where(AgentBlacklist.key_hash == cred.key_hash)
)
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:
row = await _blacklist_row(session, cred)
if row is None:
return
raise HTTPException(status_code=403, detail="agent blocked")
async def _ensure_pending_capacity(session: AsyncSession) -> None:
max_pending = get_settings().max_pending_agents
if max_pending <= 0:
return
count = (
await session.execute(
select(func.count()).select_from(Agent).where(Agent.pending_key_hash.is_not(None))
)
).scalar_one()
if count >= max_pending:
raise HTTPException(status_code=429, detail="too many pending agents")
async def _reject_hostname_collision(session: AsyncSession, agent_id: str, hostname: str) -> None:
row = (
await session.execute(
select(Agent).where(Agent.hostname == hostname, Agent.agent_id != agent_id)
)
).scalar_one_or_none()
if row is not None:
raise HTTPException(status_code=409, detail="hostname already belongs to another agent")
def _set_pending(
agent: Agent,
hb: HeartbeatRequest,
cred: AgentCredential,
observed: datetime,
features: dict,
) -> None:
agent.pending_key_hash = cred.key_hash
agent.pending_key_fingerprint = cred.key_fingerprint
agent.pending_hostname = hb.hostname
agent.pending_seen_at = observed
agent.pending_features = features
agent.pending_labels = hb.labels
async def upsert_agent_heartbeat(
session: AsyncSession, hb: HeartbeatRequest, cred: AgentCredential
) -> str:
observed = to_utc(hb.observed_at)
stmt = pg_insert(Agent).values(
agent_id=hb.agent_id,
hostname=hb.hostname,
status="alive",
last_seen_at=observed,
features=hb.features.model_dump(),
labels=hb.labels,
await reject_if_blocked(session, cred, hb.agent_id, hb.hostname, observed)
features = hb.features.model_dump()
res = await session.execute(select(Agent).where(Agent.accepted_key_hash == cred.key_hash))
accepted = res.scalar_one_or_none()
if accepted is not None:
await _reject_hostname_collision(session, accepted.agent_id, hb.hostname)
accepted.hostname = hb.hostname
accepted.status = "alive"
accepted.last_seen_at = observed
accepted.features = features
accepted.labels = hb.labels
metrics.heartbeats_total.labels(result="ok").inc()
return "accepted"
res = await session.execute(select(Agent).where(Agent.pending_key_hash == cred.key_hash))
pending = res.scalar_one_or_none()
if pending is not None:
await _reject_hostname_collision(session, pending.agent_id, hb.hostname)
_set_pending(pending, hb, cred, observed, features)
if pending.accepted_key_hash is None:
pending.hostname = hb.hostname
pending.status = "alive"
pending.last_seen_at = observed
pending.features = features
pending.labels = hb.labels
metrics.heartbeats_total.labels(result="pending").inc()
return "pending"
hostname_row = (
await session.execute(select(Agent).where(Agent.hostname == hb.hostname))
).scalar_one_or_none()
if hostname_row is not None:
if hostname_row.pending_key_hash is not None:
raise HTTPException(
status_code=409,
detail="agent already has a different pending key",
)
await _ensure_pending_capacity(session)
_set_pending(hostname_row, hb, cred, observed, features)
metrics.heartbeats_total.labels(result="pending").inc()
return "pending"
agent_id_row = (
await session.execute(select(Agent).where(Agent.agent_id == hb.agent_id))
).scalar_one_or_none()
if agent_id_row is not None:
raise HTTPException(status_code=409, detail="agent_id already exists")
await _ensure_pending_capacity(session)
session.add(
Agent(
agent_id=hb.agent_id,
hostname=hb.hostname,
status="alive",
last_seen_at=observed,
features=features,
labels=hb.labels,
pending_key_hash=cred.key_hash,
pending_key_fingerprint=cred.key_fingerprint,
pending_hostname=hb.hostname,
pending_seen_at=observed,
pending_features=features,
pending_labels=hb.labels,
)
)
stmt = stmt.on_conflict_do_update(
index_elements=[Agent.agent_id],
set_={
"hostname": stmt.excluded.hostname,
"status": "alive",
"last_seen_at": stmt.excluded.last_seen_at,
"features": stmt.excluded.features,
"labels": stmt.excluded.labels,
},
)
await session.execute(stmt)
metrics.heartbeats_total.labels(result="ok").inc()
metrics.heartbeats_total.labels(result="pending").inc()
return "pending"
async def accepted_agent_for_key(session: AsyncSession, cred: AgentCredential) -> Agent | None:
if await _blacklist_row(session, cred) is not None:
raise HTTPException(status_code=403, detail="agent blocked")
res = await session.execute(select(Agent).where(Agent.accepted_key_hash == cred.key_hash))
return res.scalar_one_or_none()
async def _apply_incident(

View File

@@ -25,6 +25,43 @@ def _backoff(attempts: int) -> timedelta:
return timedelta(seconds=_BACKOFF_SCHEDULE_SEC[idx])
def _apply_output_policy(payload: dict) -> dict:
"""PH-020: enforce a single notifier output policy.
Per-notifier code keeps its own format-specific shortening (e.g. Telegram
300 chars), but a hard cap and include-flag apply consistently before any
delivery. Truncation uses a clear marker so receivers can see it happened.
"""
settings = get_settings()
out = dict(payload)
text_value = out.get("output")
if text_value is None:
return out
if not settings.notifier_include_output:
out["output"] = None
out["output_truncated"] = True
return out
max_bytes = max(settings.notifier_max_output_bytes, 0)
encoded = text_value.encode("utf-8")
if len(encoded) <= max_bytes:
return out
# Reserve room for the marker so the final string never exceeds max_bytes.
# If max_bytes is smaller than even the marker, drop the body entirely —
# this keeps the absolute cap a hard guarantee under any setting.
dropped = len(encoded) - max_bytes
marker = f"...[truncated {dropped} bytes]"
marker_bytes = len(marker.encode("utf-8"))
if marker_bytes >= max_bytes:
out["output"] = ""
out["output_truncated"] = True
return out
prefix_budget = max_bytes - marker_bytes
truncated = encoded[:prefix_budget].decode("utf-8", errors="ignore")
out["output"] = f"{truncated}{marker}"
out["output_truncated"] = True
return out
async def _claim_batch(session, limit: int, lease_sec: int) -> list[dict]:
res = await session.execute(
text(
@@ -107,8 +144,10 @@ async def _process_row(
metrics.notifications_total.labels(notifier=row["notifier"], result="discarded").inc()
return
# PH-020: apply central output policy before handing to any notifier.
delivery_payload = _apply_output_policy(row["payload"])
try:
result = await notifier.deliver(row["event_type"], row["payload"])
result = await notifier.deliver(row["event_type"], delivery_payload)
except Exception as exc: # defensive: notifier should not raise
result_ok = False
result_perm = False

View File

@@ -13,9 +13,9 @@ class Settings(BaseSettings):
body_limit_bytes: int = 1_048_576
output_max_bytes: int = 8192
max_batch_events: int = 200
stale_after_sec: int = 90
dead_after_sec: int = 300
detector_tick_sec: int = 15
stale_after_sec: int = 20
dead_after_sec: int = 30
detector_tick_sec: int = 5
host: str = "0.0.0.0"
port: int = 8000
enable_detector: bool = True
@@ -24,8 +24,18 @@ class Settings(BaseSettings):
events_future_partitions: int = 3
event_dedup_retention_days: int = 30
event_dedup_prune_batch_size: int = 5000
max_pending_agents: int = 10_000
pending_agent_ttl_days: int = 7
pending_agent_prune_batch_size: int = 1000
partition_maintenance_interval_sec: int = 3600
outbox_retention_max_rows: int = 50_000
# PH-012: bounded prune per maintenance tick to avoid one huge DELETE.
outbox_prune_batch_size: int = 5000
# PH-016: explicit DB pool sizing for production tuning.
db_pool_size: int = 10
db_max_overflow: int = 10
db_pool_timeout_sec: float = 30.0
db_pool_recycle_sec: int = 1800
notifier_tick_sec: int = 5
notifier_batch_size: int = 20
notifier_max_attempts: int = 8
@@ -33,6 +43,11 @@ class Settings(BaseSettings):
notifier_http_timeout_sec: float = 10.0
notification_time_zone: str = "UTC"
# PH-020: notifier output policy. include controls whether output text is
# sent at all; max_bytes truncates it before delivery (after redaction).
notifier_include_output: bool = True
notifier_max_output_bytes: int = 1024
notifier_debug_enabled: bool = True
notifier_telegram_enabled: bool = False
notifier_telegram_token: str = ""
@@ -43,12 +58,26 @@ class Settings(BaseSettings):
notifier_alertmanager_enabled: bool = False
notifier_alertmanager_url: str = ""
@staticmethod
def _validate_token_list(value: str, env_name: str) -> str:
if not value or value == "changeme":
raise ValueError(f"{env_name} must be set to a non-default value")
tokens = [t for t in value.replace(",", " ").split() if t]
if not tokens:
raise ValueError(f"{env_name} must contain at least one token")
if any(t == "changeme" for t in tokens):
raise ValueError(f"{env_name} must be set to a non-default value")
return value
@field_validator("auth_token")
@classmethod
def _validate_auth_token(cls, value: str) -> str:
if not value or value == "changeme":
raise ValueError("MONLET_AUTH_TOKEN must be set to a non-default value")
return value
return cls._validate_token_list(value, "MONLET_AUTH_TOKEN")
@property
def auth_tokens(self) -> list[str]:
"""Active Bearer tokens (>=1). Multiple tokens enable overlap rotation."""
return [t for t in self.auth_token.replace(",", " ").split() if t]
@field_validator("notification_time_zone")
@classmethod

View File

@@ -49,7 +49,28 @@ def make_heartbeat(agent_id: str = "agent-1") -> dict:
return {
"agent_id": agent_id,
"observed_at": now_iso(),
"hostname": "host-1",
"hostname": agent_id,
"features": {"push": True, "metrics": True},
"labels": {},
}
def agent_token(agent_id: str = "agent-1") -> str:
safe = "".join(ch if ch.isalnum() else "0" for ch in agent_id)
return f"test-agent-key-{safe}-00000000000000000000000000000000"
def agent_headers(agent_id: str = "agent-1") -> dict[str, str]:
return {"Authorization": f"Bearer {agent_token(agent_id)}"}
async def register_agent(
client, ui_headers: dict[str, str], agent_id: str = "agent-1"
) -> dict[str, str]:
headers = agent_headers(agent_id)
r = await client.post("/api/v1/heartbeat", json=make_heartbeat(agent_id), headers=headers)
assert r.status_code == 202
r = await client.post(f"/api/v1/agents/{agent_id}/accept", headers=ui_headers)
assert r.status_code == 200
assert r.json()["count"] == 1
return headers

View File

@@ -13,7 +13,7 @@ from testcontainers.postgres import PostgresContainer
from alembic import command
os.environ.setdefault("MONLET_AUTH_TOKEN", "test-token")
os.environ.setdefault("MONLET_AUTH_TOKEN", "test-ui-token")
os.environ.setdefault("MONLET_ENABLE_DETECTOR", "false")
os.environ.setdefault("MONLET_ENABLE_NOTIFIER_WORKER", "false")
@@ -97,7 +97,7 @@ async def _truncate_tables(engine) -> AsyncIterator[None]:
async with engine.begin() as conn:
await conn.execute(
text(
"TRUNCATE TABLE notification_outbox, incidents, events, event_ingest_dedup, checks, agents RESTART IDENTITY CASCADE"
"TRUNCATE TABLE notification_outbox, incidents, events, event_ingest_dedup, checks, agent_blacklist, agents RESTART IDENTITY CASCADE"
)
)
@@ -116,4 +116,11 @@ async def app_client(database_url: str) -> AsyncIterator[AsyncClient]:
@pytest.fixture
def auth_headers() -> dict[str, str]:
return {"Authorization": "Bearer test-token"}
from tests._helpers import agent_headers
return agent_headers()
@pytest.fixture
def ui_auth_headers() -> dict[str, str]:
return {"Authorization": "Bearer test-ui-token"}

View File

@@ -0,0 +1,354 @@
import pytest
from sqlalchemy import func, select
from monlet_server.models import Agent, AgentBlacklist, Check
from monlet_server.services.detector import _prune_history
from monlet_server.settings import reset_settings_cache
from ._helpers import agent_headers, make_event, make_heartbeat, register_agent
@pytest.mark.asyncio
async def test_unknown_heartbeat_creates_pending_agent(app_client, auth_headers, session):
r = await app_client.post(
"/api/v1/heartbeat", json=make_heartbeat("agent-pending"), headers=auth_headers
)
assert r.status_code == 202
agent = (
await session.execute(select(Agent).where(Agent.agent_id == "agent-pending"))
).scalar_one()
assert agent.pending_key_hash
assert agent.accepted_key_hash is None
@pytest.mark.asyncio
async def test_pending_events_are_dropped_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}
n = (await session.execute(select(func.count()).select_from(Check))).scalar_one()
assert n == 0
@pytest.mark.asyncio
async def test_accept_enables_events(app_client, ui_auth_headers, session):
auth_headers = await register_agent(app_client, ui_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.json() == {"accepted": 1, "deduplicated": 0}
n = (await session.execute(select(func.count()).select_from(Check))).scalar_one()
assert n == 1
@pytest.mark.asyncio
async def test_block_pending_key(app_client, auth_headers, ui_auth_headers, session):
await app_client.post(
"/api/v1/heartbeat", json=make_heartbeat("agent-block"), headers=auth_headers
)
r = await app_client.post("/api/v1/agents/agent-block/block", headers=ui_auth_headers)
assert r.status_code == 200
assert r.json()["count"] == 1
assert (await session.execute(select(func.count()).select_from(Agent))).scalar_one() == 0
assert (
await session.execute(select(func.count()).select_from(AgentBlacklist))
).scalar_one() == 1
r = await app_client.post(
"/api/v1/heartbeat", json=make_heartbeat("agent-block"), headers=auth_headers
)
assert r.status_code == 403
assert r.json()["error"]["code"] == "agent_blocked"
@pytest.mark.asyncio
async def test_blacklist_list_delete_and_clear(app_client, auth_headers, ui_auth_headers):
await app_client.post(
"/api/v1/heartbeat", json=make_heartbeat("agent-block-a"), headers=auth_headers
)
await app_client.post("/api/v1/agents/agent-block-a/block", headers=ui_auth_headers)
other_headers = agent_headers("agent-block-b")
await app_client.post(
"/api/v1/heartbeat",
json=make_heartbeat("agent-block-b"),
headers=other_headers,
)
await app_client.post("/api/v1/agents/agent-block-b/block", headers=ui_auth_headers)
r = await app_client.get("/api/v1/agents/admission/blacklist", headers=ui_auth_headers)
assert r.status_code == 200
items = r.json()["items"]
assert len(items) == 2
blocked_id = items[0]["id"]
r = await app_client.delete(
f"/api/v1/agents/admission/blacklist/{blocked_id}",
headers=ui_auth_headers,
)
assert r.json()["count"] == 1
r = await app_client.delete("/api/v1/agents/admission/blacklist", headers=ui_auth_headers)
assert r.json()["count"] == 1
@pytest.mark.asyncio
async def test_remove_accepted_agent_reappears_pending(app_client, ui_auth_headers):
auth_headers = await register_agent(app_client, ui_auth_headers, "agent-remove")
r = await app_client.delete("/api/v1/agents/agent-remove", headers=ui_auth_headers)
assert r.status_code == 200
r = await app_client.post(
"/api/v1/heartbeat", json=make_heartbeat("agent-remove"), headers=auth_headers
)
assert r.status_code == 202
r = await app_client.get("/api/v1/agents/agent-remove", headers=ui_auth_headers)
assert r.json()["admission_state"] == "pending"
@pytest.mark.asyncio
async def test_new_key_same_hostname_is_pending_replacement(app_client, ui_auth_headers, session):
old_headers = await register_agent(app_client, ui_auth_headers, "old-id")
hb = make_heartbeat("new-id")
hb["hostname"] = "old-id"
new_headers = agent_headers("new-id")
r = await app_client.post("/api/v1/heartbeat", json=hb, headers=new_headers)
assert r.status_code == 202
r = await app_client.get("/api/v1/agents/old-id", headers=ui_auth_headers)
assert r.json()["admission_state"] == "pending"
assert r.json()["rotation_pending"] is True
ev = make_event(status="ok")
r = await app_client.post(
"/api/v1/events", json={"agent_id": "old-id", "events": [ev]}, headers=old_headers
)
assert r.json()["accepted"] == 1
r = await app_client.post("/api/v1/agents/old-id/block", headers=ui_auth_headers)
assert r.json()["count"] == 1
agent = (await session.execute(select(Agent).where(Agent.agent_id == "old-id"))).scalar_one()
assert agent.accepted_key_hash
assert agent.pending_key_hash is None
@pytest.mark.asyncio
async def test_accept_all_skips_rotations_without_explicit_flag(
app_client, ui_auth_headers, session
):
await register_agent(app_client, ui_auth_headers, "stable-host")
hb = make_heartbeat("replacement-id")
hb["hostname"] = "stable-host"
await app_client.post("/api/v1/heartbeat", json=hb, headers=agent_headers("replacement"))
await app_client.post(
"/api/v1/heartbeat", json=make_heartbeat("new-host"), headers=agent_headers("new-host")
)
r = await app_client.post("/api/v1/agents/admission/accept-all", headers=ui_auth_headers)
assert r.status_code == 200
assert r.json()["count"] == 1
stable = (
await session.execute(select(Agent).where(Agent.agent_id == "stable-host"))
).scalar_one()
assert stable.pending_key_hash is not None
new_host = (
await session.execute(select(Agent).where(Agent.agent_id == "new-host"))
).scalar_one()
assert new_host.pending_key_hash is None
assert new_host.accepted_key_hash is not None
r = await app_client.post(
"/api/v1/agents/admission/accept-all?include_rotation=true",
headers=ui_auth_headers,
)
assert r.status_code == 200
assert r.json()["count"] == 1
await session.refresh(stable)
assert stable.pending_key_hash is None
@pytest.mark.asyncio
async def test_new_key_cannot_hijack_existing_pending_hostname(app_client, auth_headers):
await app_client.post(
"/api/v1/heartbeat", json=make_heartbeat("pending-host"), headers=auth_headers
)
r = await app_client.post(
"/api/v1/heartbeat",
json=make_heartbeat("pending-host"),
headers=agent_headers("other-key"),
)
assert r.status_code == 409
assert r.json()["error"]["code"] == "conflict"
@pytest.mark.asyncio
async def test_new_key_agent_id_collision_is_rejected(app_client, ui_auth_headers):
await register_agent(app_client, ui_auth_headers, "agent-a")
await register_agent(app_client, ui_auth_headers, "agent-b")
hb = make_heartbeat("agent-a")
hb["hostname"] = "new-host"
r = await app_client.post("/api/v1/heartbeat", json=hb, headers=agent_headers("new-key"))
assert r.status_code == 409
assert r.json()["error"]["code"] == "conflict"
@pytest.mark.asyncio
async def test_accepted_key_cannot_take_another_hostname(app_client, ui_auth_headers):
headers = await register_agent(app_client, ui_auth_headers, "agent-a")
await register_agent(app_client, ui_auth_headers, "agent-b")
hb = make_heartbeat("agent-a")
hb["hostname"] = "agent-b"
r = await app_client.post("/api/v1/heartbeat", json=hb, headers=headers)
assert r.status_code == 409
assert r.json()["error"]["code"] == "conflict"
@pytest.mark.asyncio
async def test_hostname_replacement_still_wins_over_body_agent_id(app_client, ui_auth_headers):
await register_agent(app_client, ui_auth_headers, "agent-a")
await register_agent(app_client, ui_auth_headers, "agent-b")
hb = make_heartbeat("agent-a")
hb["hostname"] = "agent-b"
r = await app_client.post("/api/v1/heartbeat", json=hb, headers=agent_headers("new-key"))
assert r.status_code == 202
assert (await app_client.get("/api/v1/agents/agent-b", headers=ui_auth_headers)).json()[
"admission_state"
] == "pending"
assert (await app_client.get("/api/v1/agents/agent-a", headers=ui_auth_headers)).json()[
"admission_state"
] == "accepted"
@pytest.mark.asyncio
async def test_remove_pending_replacement_keeps_accepted_agent(
app_client, ui_auth_headers, session
):
old_headers = await register_agent(app_client, ui_auth_headers, "stable-host")
hb = make_heartbeat("replacement-id")
hb["hostname"] = "stable-host"
r = await app_client.post("/api/v1/heartbeat", json=hb, headers=agent_headers("replacement"))
assert r.status_code == 202
r = await app_client.delete("/api/v1/agents/stable-host", headers=ui_auth_headers)
assert r.status_code == 200
agent = (
await session.execute(select(Agent).where(Agent.agent_id == "stable-host"))
).scalar_one()
assert agent.accepted_key_hash
assert agent.pending_key_hash is None
ev = make_event(status="ok")
r = await app_client.post(
"/api/v1/events", json={"agent_id": "stable-host", "events": [ev]}, headers=old_headers
)
assert r.json()["accepted"] == 1
@pytest.mark.asyncio
async def test_pending_agent_cap_rejects_new_pending(app_client, auth_headers, monkeypatch):
monkeypatch.setenv("MONLET_MAX_PENDING_AGENTS", "1")
reset_settings_cache()
r = await app_client.post("/api/v1/heartbeat", json=make_heartbeat("one"), headers=auth_headers)
assert r.status_code == 202
r = await app_client.post(
"/api/v1/heartbeat", json=make_heartbeat("two"), headers=agent_headers("two")
)
assert r.status_code == 429
assert r.json()["error"]["code"] == "rate_limited"
monkeypatch.delenv("MONLET_MAX_PENDING_AGENTS", raising=False)
reset_settings_cache()
@pytest.mark.asyncio
async def test_pending_prune_deletes_pending_only_and_clears_replacement(
app_client, ui_auth_headers, session, monkeypatch
):
from datetime import UTC, datetime, timedelta
await app_client.post(
"/api/v1/heartbeat", json=make_heartbeat("pending-only"), headers=agent_headers("p1")
)
await register_agent(app_client, ui_auth_headers, "accepted")
hb = make_heartbeat("replacement")
hb["hostname"] = "accepted"
await app_client.post("/api/v1/heartbeat", json=hb, headers=agent_headers("p2"))
old = datetime.now(UTC) - timedelta(days=2)
rows = (
(await session.execute(select(Agent).where(Agent.pending_key_hash.is_not(None))))
.scalars()
.all()
)
for row in rows:
row.pending_seen_at = old
await session.commit()
monkeypatch.setenv("MONLET_PENDING_AGENT_TTL_DAYS", "1")
reset_settings_cache()
await _prune_history(session)
await session.commit()
assert (
await session.execute(select(Agent).where(Agent.agent_id == "pending-only"))
).scalar_one_or_none() is None
accepted = (
await session.execute(select(Agent).where(Agent.agent_id == "accepted"))
).scalar_one()
assert accepted.accepted_key_hash
assert accepted.pending_key_hash is None
monkeypatch.delenv("MONLET_PENDING_AGENT_TTL_DAYS", raising=False)
reset_settings_cache()
@pytest.mark.asyncio
async def test_blacklist_delete_uses_unique_row_id(app_client, ui_auth_headers, session):
from datetime import UTC, datetime
now = datetime.now(UTC)
same_fingerprint = "0123456789abcdef"
first_hash = "a" * 64
second_hash = "b" * 64
session.add_all(
[
AgentBlacklist(
key_hash=first_hash,
key_fingerprint=same_fingerprint,
agent_id="agent-a",
hostname="host-a",
last_seen_at=now,
),
AgentBlacklist(
key_hash=second_hash,
key_fingerprint=same_fingerprint,
agent_id="agent-b",
hostname="host-b",
last_seen_at=now,
),
]
)
await session.commit()
r = await app_client.get("/api/v1/agents/admission/blacklist", headers=ui_auth_headers)
assert r.status_code == 200
items = r.json()["items"]
assert [i["id"] for i in items] == [first_hash, second_hash]
r = await app_client.delete(
f"/api/v1/agents/admission/blacklist/{first_hash}",
headers=ui_auth_headers,
)
assert r.json()["count"] == 1
r = await app_client.get("/api/v1/agents/admission/blacklist", headers=ui_auth_headers)
assert [i["id"] for i in r.json()["items"]] == [second_hash]

View File

@@ -30,6 +30,69 @@ async def test_wrong_token_401(app_client):
@pytest.mark.asyncio
async def test_valid_token_200(app_client, auth_headers):
r = await app_client.get("/api/v1/agents", headers=auth_headers)
async def test_valid_token_200(app_client, ui_auth_headers):
r = await app_client.get("/api/v1/agents", headers=ui_auth_headers)
assert r.status_code == 200
@pytest.mark.asyncio
async def test_ui_token_is_separate_from_agent_self_key(monkeypatch, database_url):
from httpx import ASGITransport, AsyncClient
from monlet_server import db as db_module
from monlet_server.main import create_app
from monlet_server.settings import reset_settings_cache
from tests._helpers import agent_headers, make_heartbeat
monkeypatch.setenv("MONLET_AUTH_TOKEN", "ui-token")
reset_settings_cache()
await db_module.dispose_engine()
app = create_app()
async with app.router.lifespan_context(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
ui = {"Authorization": "Bearer ui-token"}
agent_a = agent_headers("agent-a")
agent_b = agent_headers("agent-b")
assert (await client.get("/api/v1/agents", headers=ui)).status_code == 200
assert (await client.get("/api/v1/agents", headers=agent_a)).status_code == 401
r = await client.post(
"/api/v1/heartbeat", json=make_heartbeat("agent-a"), headers=agent_a
)
assert r.status_code == 202
r = await client.post(
"/api/v1/heartbeat", json=make_heartbeat("agent-b"), headers=agent_b
)
assert r.status_code == 202
r = await client.post("/api/v1/heartbeat", json=make_heartbeat("agent-c"), headers=ui)
assert r.status_code == 401
await db_module.dispose_engine()
monkeypatch.setenv("MONLET_AUTH_TOKEN", "test-ui-token")
reset_settings_cache()
@pytest.mark.asyncio
async def test_multi_token_overlap(monkeypatch, database_url):
"""Two active tokens both authenticate; rotation can overlap without 401s."""
from httpx import ASGITransport, AsyncClient
from monlet_server import db as db_module
from monlet_server.main import create_app
from monlet_server.settings import reset_settings_cache
monkeypatch.setenv("MONLET_AUTH_TOKEN", "old-token new-token")
reset_settings_cache()
await db_module.dispose_engine()
app = create_app()
async with app.router.lifespan_context(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
for tok in ("old-token", "new-token"):
r = await client.get("/api/v1/agents", headers={"Authorization": f"Bearer {tok}"})
assert r.status_code == 200, tok
r = await client.get("/api/v1/agents", headers={"Authorization": "Bearer revoked"})
assert r.status_code == 401
await db_module.dispose_engine()
monkeypatch.setenv("MONLET_AUTH_TOKEN", "test-ui-token")
reset_settings_cache()

View File

@@ -9,20 +9,14 @@ from monlet_server.models import Agent, Check, Event, Incident, NotificationOutb
from monlet_server.services.detector import LIVENESS_CHECK_ID, _tick
from monlet_server.settings import reset_settings_cache
from ._helpers import make_event, make_heartbeat
from ._helpers import make_event, register_agent
@pytest.mark.asyncio
async def test_detector_transitions(app_client, auth_headers, engine, session):
await app_client.post(
"/api/v1/heartbeat", json=make_heartbeat("agent-alive"), headers=auth_headers
)
await app_client.post(
"/api/v1/heartbeat", json=make_heartbeat("agent-stale"), headers=auth_headers
)
await app_client.post(
"/api/v1/heartbeat", json=make_heartbeat("agent-dead"), headers=auth_headers
)
async def test_detector_transitions(app_client, ui_auth_headers, engine, session):
await register_agent(app_client, ui_auth_headers, "agent-alive")
await register_agent(app_client, ui_auth_headers, "agent-stale")
await register_agent(app_client, ui_auth_headers, "agent-dead")
now = datetime.now(UTC)
await session.execute(
@@ -82,10 +76,57 @@ async def test_detector_transitions(app_client, auth_headers, engine, session):
@pytest.mark.asyncio
async def test_detector_owns_liveness_check_and_events(app_client, auth_headers, engine, session):
await app_client.post(
"/api/v1/heartbeat", json=make_heartbeat("agent-flap"), headers=auth_headers
async def test_detector_noop_tick_does_not_rewrite_liveness(
app_client, ui_auth_headers, engine, session
):
"""PH-009: when no agent's liveness status changes, the detector must not
rewrite the liveness Check rows. We verify by tagging last_observed_at to a
sentinel before the tick and checking the row was untouched."""
await register_agent(app_client, ui_auth_headers, "agent-still")
sm = async_sessionmaker(engine, expire_on_commit=False)
# Force a transition first so a liveness Check row exists.
await session.execute(
update(Agent)
.where(Agent.agent_id == "agent-still")
.values(last_seen_at=datetime.now(UTC) - timedelta(minutes=10))
)
await session.commit()
await _tick(sm)
session.expire_all()
row_before = (
await session.execute(
select(Check).where(
Check.agent_id == "agent-still", Check.check_id == LIVENESS_CHECK_ID
)
)
).scalar_one()
sentinel = datetime(2030, 1, 1, tzinfo=UTC)
await session.execute(
update(Check)
.where(Check.agent_id == "agent-still", Check.check_id == LIVENESS_CHECK_ID)
.values(last_observed_at=sentinel)
)
await session.commit()
# Second tick with no liveness change: row must NOT be rewritten.
await _tick(sm)
session.expire_all()
row_after = (
await session.execute(
select(Check).where(
Check.agent_id == "agent-still", Check.check_id == LIVENESS_CHECK_ID
)
)
).scalar_one()
assert row_after.last_observed_at == sentinel
assert row_after.last_event_id == row_before.last_event_id
@pytest.mark.asyncio
async def test_detector_owns_liveness_check_and_events(
app_client, ui_auth_headers, engine, session
):
await register_agent(app_client, ui_auth_headers, "agent-flap")
sm = async_sessionmaker(engine, expire_on_commit=False)
# Tick 1: alive → Check exists with status=ok, one event.
@@ -144,13 +185,11 @@ async def test_detector_owns_liveness_check_and_events(app_client, auth_headers,
@pytest.mark.asyncio
async def test_detector_prunes_outbox(app_client, auth_headers, engine, session, monkeypatch):
async def test_detector_prunes_outbox(app_client, ui_auth_headers, engine, session, monkeypatch):
monkeypatch.setenv("MONLET_OUTBOX_RETENTION_MAX_ROWS", "2")
reset_settings_cache()
try:
await app_client.post(
"/api/v1/heartbeat", json=make_heartbeat("agent-prune"), headers=auth_headers
)
auth_headers = await register_agent(app_client, ui_auth_headers, "agent-prune")
critical = make_event(check_id="critical_prune", status="critical", exit_code=2)
await app_client.post(
"/api/v1/events",
@@ -192,3 +231,73 @@ async def test_detector_prunes_outbox(app_client, auth_headers, engine, session,
assert terminal_outbox == 2
finally:
reset_settings_cache()
@pytest.mark.asyncio
async def test_detector_prune_preserves_active_outbox(
app_client, ui_auth_headers, engine, session, monkeypatch
):
"""PH-012: prune must never delete active rows (pending/sending/retry),
even when retention is exceeded. The keep-N-newest list applies only to
terminal states; active rows are filtered out of the eligible set."""
monkeypatch.setenv("MONLET_OUTBOX_RETENTION_MAX_ROWS", "1")
reset_settings_cache()
try:
auth_headers = await register_agent(app_client, ui_auth_headers, "agent-prune-active")
critical = make_event(check_id="active_prune", status="critical", exit_code=2)
await app_client.post(
"/api/v1/events",
json={"agent_id": "agent-prune-active", "events": [critical]},
headers=auth_headers,
)
inc = (
await session.execute(select(Incident).where(Incident.check_id == "active_prune"))
).scalar_one()
now = datetime.now(UTC)
# Mix of active and terminal states.
for state, count in (("sent", 5), ("pending", 3), ("retry", 2), ("sending", 1)):
for _ in range(count):
session.add(
NotificationOutbox(
id=uuid4(),
incident_id=inc.id,
notifier="debug",
event_type="firing",
state=state,
attempts=1,
next_attempt_at=None,
payload={},
created_at=now,
updated_at=now,
)
)
await session.commit()
sm = async_sessionmaker(engine, expire_on_commit=False)
await _tick(sm)
# Active rows must be untouched. Ingestion may add extra pending rows
# for the debug notifier; we only assert ours are still present and
# that no active rows were deleted by the prune.
for state, injected in (("pending", 3), ("retry", 2), ("sending", 1)):
n = (
await session.execute(
select(func.count())
.select_from(NotificationOutbox)
.where(NotificationOutbox.state == state)
)
).scalar_one()
assert n >= injected, (state, n, injected)
# Terminal rows must be capped to retention=1 (or 2 after notifier worker
# produces extras from the critical event we ingested; but worker is
# disabled in tests so we just check terminal pruning).
sent = (
await session.execute(
select(func.count())
.select_from(NotificationOutbox)
.where(NotificationOutbox.state == "sent")
)
).scalar_one()
assert sent <= 2 # 1 retained + at most 1 created by detector enqueue
finally:
reset_settings_cache()

View File

@@ -1,14 +1,14 @@
import pytest
from sqlalchemy import func, select
from monlet_server.models import Check, Event
from monlet_server.models import Agent, Check, Event
from ._helpers import make_event, make_heartbeat
from ._helpers import make_event, register_agent
@pytest.mark.asyncio
async def test_event_accepted_and_state(app_client, auth_headers, session):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
async def test_event_accepted_and_state(app_client, ui_auth_headers, session):
auth_headers = await register_agent(app_client, ui_auth_headers)
ev = make_event(status="ok", output="disk ok")
r = await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
@@ -23,8 +23,20 @@ async def test_event_accepted_and_state(app_client, auth_headers, session):
@pytest.mark.asyncio
async def test_duplicate_event_deduplicated(app_client, auth_headers):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
async def test_event_before_heartbeat_is_dropped(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}
n = (await session.execute(select(func.count()).select_from(Agent))).scalar_one()
assert n == 0
@pytest.mark.asyncio
async def test_duplicate_event_deduplicated(app_client, ui_auth_headers):
auth_headers = await register_agent(app_client, ui_auth_headers)
ev = make_event(status="critical", exit_code=2)
r1 = await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
@@ -46,8 +58,8 @@ async def test_empty_batch_400(app_client, auth_headers):
@pytest.mark.asyncio
@pytest.mark.parametrize("observed_at", ["2026-05-27T09:00:00", "2026-05-27T09:00:00+04:00"])
async def test_event_requires_utc_timestamp(app_client, auth_headers, observed_at):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
async def test_event_requires_utc_timestamp(app_client, ui_auth_headers, observed_at):
auth_headers = await register_agent(app_client, ui_auth_headers)
ev = make_event(observed_at=observed_at)
r = await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
@@ -66,8 +78,8 @@ async def test_oversize_batch_400(app_client, auth_headers):
@pytest.mark.asyncio
async def test_repeated_same_status_not_stored(app_client, auth_headers, session):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
async def test_repeated_same_status_not_stored(app_client, ui_auth_headers, session):
auth_headers = await register_agent(app_client, ui_auth_headers)
evs = [make_event(status="ok") for _ in range(5)]
r = await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": evs}, headers=auth_headers
@@ -79,8 +91,8 @@ async def test_repeated_same_status_not_stored(app_client, auth_headers, session
@pytest.mark.asyncio
async def test_status_transitions_stored(app_client, auth_headers, session):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
async def test_status_transitions_stored(app_client, ui_auth_headers, session):
auth_headers = await register_agent(app_client, ui_auth_headers)
seq = [
make_event(status="ok"),
make_event(status="warning", exit_code=1),
@@ -98,8 +110,8 @@ async def test_status_transitions_stored(app_client, auth_headers, session):
@pytest.mark.asyncio
async def test_same_status_different_exit_code_stored(app_client, auth_headers, session):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
async def test_same_status_different_exit_code_stored(app_client, ui_auth_headers, session):
auth_headers = await register_agent(app_client, ui_auth_headers)
seq = [
make_event(status="warning", exit_code=1),
make_event(status="warning", exit_code=3),
@@ -113,10 +125,10 @@ async def test_same_status_different_exit_code_stored(app_client, auth_headers,
@pytest.mark.asyncio
async def test_late_event_does_not_move_state(app_client, auth_headers, session):
async def test_late_event_does_not_move_state(app_client, ui_auth_headers, session):
from datetime import UTC, datetime, timedelta
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
auth_headers = await register_agent(app_client, ui_auth_headers)
newer = datetime.now(UTC).replace(microsecond=0)
older = (newer - timedelta(minutes=5)).isoformat()
ev_now = make_event(

View File

@@ -12,8 +12,10 @@ async def test_heartbeat_creates_agent(app_client, auth_headers, session):
assert r.status_code == 202
res = await session.execute(select(Agent).where(Agent.agent_id == "agent-1"))
a = res.scalar_one()
assert a.hostname == "host-1"
assert a.hostname == "agent-1"
assert a.status == "alive"
assert a.pending_key_hash
assert a.accepted_key_hash is None
@pytest.mark.asyncio
@@ -24,7 +26,7 @@ async def test_heartbeat_upsert(app_client, auth_headers, session):
await app_client.post("/api/v1/heartbeat", json=hb, headers=auth_headers)
res = await session.execute(select(Agent).where(Agent.agent_id == "agent-1"))
a = res.scalar_one()
assert a.features == {"push": True, "metrics": False}
assert a.pending_features == {"push": True, "metrics": False}
@pytest.mark.asyncio

View File

@@ -1,30 +1,30 @@
import pytest
from ._helpers import make_event, make_heartbeat
from ._helpers import make_event, register_agent
@pytest.mark.asyncio
async def test_incident_key_ownership_rejected(app_client, auth_headers):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat("agent-1"), headers=auth_headers)
await app_client.post("/api/v1/heartbeat", json=make_heartbeat("agent-2"), headers=auth_headers)
async def test_incident_key_ownership_rejected(app_client, ui_auth_headers):
agent_1_headers = await register_agent(app_client, ui_auth_headers, "agent-1")
agent_2_headers = await register_agent(app_client, ui_auth_headers, "agent-2")
ev1 = make_event(status="critical", exit_code=2, incident_key="shared-key")
r1 = await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev1]}, headers=auth_headers
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev1]}, headers=agent_1_headers
)
assert r1.status_code == 202
ev2 = make_event(status="critical", exit_code=2, incident_key="shared-key")
r2 = await app_client.post(
"/api/v1/events", json={"agent_id": "agent-2", "events": [ev2]}, headers=auth_headers
"/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"
@pytest.mark.asyncio
async def test_incident_key_too_long(app_client, auth_headers):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
async def test_incident_key_too_long(app_client, ui_auth_headers):
auth_headers = await register_agent(app_client, ui_auth_headers)
ev = make_event(status="critical", exit_code=2, incident_key="x" * 257)
r = await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
@@ -33,20 +33,20 @@ async def test_incident_key_too_long(app_client, auth_headers):
@pytest.mark.asyncio
async def test_events_query_cursor_pagination(app_client, auth_headers):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
async def test_events_query_cursor_pagination(app_client, auth_headers, ui_auth_headers):
auth_headers = await register_agent(app_client, ui_auth_headers)
evs = [make_event(check_id=f"chk-{i}") for i in range(5)]
await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": evs}, headers=auth_headers
)
r = await app_client.get("/api/v1/events/query?limit=2", headers=auth_headers)
r = await app_client.get("/api/v1/events/query?limit=2", headers=ui_auth_headers)
assert r.status_code == 200
page1 = r.json()
assert len(page1["items"]) == 2
assert page1["next_cursor"]
r2 = await app_client.get(
f"/api/v1/events/query?limit=2&cursor={page1['next_cursor']}", headers=auth_headers
f"/api/v1/events/query?limit=2&cursor={page1['next_cursor']}", headers=ui_auth_headers
)
page2 = r2.json()
assert len(page2["items"]) == 2

View File

@@ -3,12 +3,12 @@ from sqlalchemy import select
from monlet_server.models import Incident, NotificationOutbox
from ._helpers import make_event, make_heartbeat
from ._helpers import make_event, register_agent
@pytest.mark.asyncio
async def test_open_escalate_resolve_reopen(app_client, auth_headers, session):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
async def test_open_escalate_resolve_reopen(app_client, ui_auth_headers, session):
auth_headers = await register_agent(app_client, ui_auth_headers)
a = "agent-1"
# warning -> open warning
@@ -52,8 +52,8 @@ async def test_open_escalate_resolve_reopen(app_client, auth_headers, session):
@pytest.mark.asyncio
async def test_outbox_skipped_when_notifications_disabled(app_client, auth_headers, session):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
async def test_outbox_skipped_when_notifications_disabled(app_client, ui_auth_headers, session):
auth_headers = await register_agent(app_client, ui_auth_headers)
ev = make_event(status="critical", exit_code=2, notifications_enabled=False)
await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
@@ -63,8 +63,8 @@ async def test_outbox_skipped_when_notifications_disabled(app_client, auth_heade
@pytest.mark.asyncio
async def test_outbox_firing_and_resolved(app_client, auth_headers, session):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
async def test_outbox_firing_and_resolved(app_client, ui_auth_headers, session):
auth_headers = await register_agent(app_client, ui_auth_headers)
a = "agent-1"
ev_open = make_event(status="critical", exit_code=2)
await app_client.post(
@@ -77,3 +77,42 @@ async def test_outbox_firing_and_resolved(app_client, auth_headers, session):
rows = (await session.execute(select(NotificationOutbox))).scalars().all()
types = sorted(r.event_type for r in rows)
assert types == ["firing", "resolved"]
def test_status_rank_map_covers_all_pairs():
"""PH-review: the SQL-side rank is computed by a STORED generated column
(`incidents.status_rank`). The Python `_STATUS_RANK` map mirrors that CASE
for cursor encoding only. This test pins the map so the two cannot drift —
if a new (state, severity) pair is added, both must update."""
from monlet_server.api.incidents import _STATUS_RANK
assert _STATUS_RANK == {
("open", "critical"): 5,
("open", "warning"): 4,
("open", "unknown"): 3,
("resolved", "critical"): 2,
("resolved", "warning"): 1,
("resolved", "unknown"): 0,
}
@pytest.mark.asyncio
async def test_status_rank_generated_column_matches_python_map(
app_client, ui_auth_headers, session
):
"""End-to-end check: insert one event per (state, severity) pair and
confirm the DB-side generated column equals the Python map value."""
from monlet_server.api.incidents import _STATUS_RANK
# Open critical incident comes from a single critical event.
auth_headers = await register_agent(app_client, ui_auth_headers)
crit = make_event(check_id="crit", status="critical", exit_code=2)
warn = make_event(check_id="warn", status="warning", exit_code=1)
await app_client.post(
"/api/v1/events",
json={"agent_id": "agent-1", "events": [crit, warn]},
headers=auth_headers,
)
rows = (await session.execute(select(Incident))).scalars().all()
for r in rows:
assert r.status_rank == _STATUS_RANK[(r.state, r.severity)]

View File

@@ -10,7 +10,7 @@ EXAMPLES = os.path.join(os.path.dirname(__file__), "..", "..", "examples")
@pytest.mark.asyncio
async def test_examples_payload(app_client, auth_headers, session):
async def test_examples_payload(app_client, auth_headers, ui_auth_headers, session):
with open(os.path.join(EXAMPLES, "heartbeat.json")) as f:
hb = json.load(f)
with open(os.path.join(EXAMPLES, "event-batch.json")) as f:
@@ -18,6 +18,8 @@ async def test_examples_payload(app_client, auth_headers, session):
r = await app_client.post("/api/v1/heartbeat", json=hb, headers=auth_headers)
assert r.status_code == 202
r = await app_client.post(f"/api/v1/agents/{hb['agent_id']}/accept", headers=ui_auth_headers)
assert r.status_code == 200
r = await app_client.post("/api/v1/events", json=batch, headers=auth_headers)
assert r.status_code == 202

View File

@@ -200,7 +200,7 @@ async def _seed_incident_and_outbox(
event_type="firing",
state="pending",
attempts=0,
next_attempt_at=datetime.now(UTC),
next_attempt_at=datetime.now(UTC) - timedelta(seconds=1),
payload=_payload(),
)
session.add(row)
@@ -386,7 +386,7 @@ async def test_telegram_redacts_secret_in_incident_key():
@pytest.mark.asyncio
async def test_fanout_creates_row_per_enabled_notifier(
app_client, auth_headers, session, monkeypatch
app_client, ui_auth_headers, session, monkeypatch
):
# Enable telegram (token+chat_id satisfied) and webhook in addition to debug.
os.environ["MONLET_NOTIFIER_TELEGRAM_ENABLED"] = "true"
@@ -398,9 +398,9 @@ async def test_fanout_creates_row_per_enabled_notifier(
reset_settings_cache()
try:
from tests._helpers import make_event, make_heartbeat
from tests._helpers import make_event, register_agent
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
auth_headers = await register_agent(app_client, ui_auth_headers)
ev = make_event(status="critical", exit_code=2)
r = await app_client.post(
"/api/v1/events",

View File

@@ -1,21 +1,19 @@
import pytest
from ._helpers import make_event, make_heartbeat
from ._helpers import agent_headers, make_event, make_heartbeat, register_agent
@pytest.mark.asyncio
async def test_agents_cursor(app_client, auth_headers):
async def test_agents_cursor(app_client, ui_auth_headers):
for i in range(5):
await app_client.post(
"/api/v1/heartbeat", json=make_heartbeat(f"a-{i}"), headers=auth_headers
)
r1 = await app_client.get("/api/v1/agents?limit=2", headers=auth_headers)
await register_agent(app_client, ui_auth_headers, f"a-{i}")
r1 = await app_client.get("/api/v1/agents?limit=2", headers=ui_auth_headers)
p1 = r1.json()
assert len(p1["items"]) == 2
assert p1["next_cursor"]
r2 = await app_client.get(
f"/api/v1/agents?limit=2&cursor={p1['next_cursor']}", headers=auth_headers
f"/api/v1/agents?limit=2&cursor={p1['next_cursor']}", headers=ui_auth_headers
)
p2 = r2.json()
assert len(p2["items"]) == 2
@@ -25,76 +23,204 @@ async def test_agents_cursor(app_client, auth_headers):
@pytest.mark.asyncio
async def test_checks_cursor(app_client, auth_headers):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
async def test_agents_cursor_sorts_pending_before_accepted(app_client, ui_auth_headers):
for i in range(3):
await register_agent(app_client, ui_auth_headers, f"accepted-{i}")
for i in range(2):
await app_client.post(
"/api/v1/heartbeat",
json=make_heartbeat(f"pending-{i}"),
headers=agent_headers(f"pending-{i}"),
)
r1 = await app_client.get("/api/v1/agents?limit=3", headers=ui_auth_headers)
p1 = r1.json()
assert [i["admission_state"] for i in p1["items"][:2]] == ["pending", "pending"]
assert p1["next_cursor"]
r2 = await app_client.get(
f"/api/v1/agents?limit=3&cursor={p1['next_cursor']}", headers=ui_auth_headers
)
assert {i["agent_id"] for i in r2.json()["items"]}.isdisjoint(
{i["agent_id"] for i in p1["items"]}
)
@pytest.mark.asyncio
async def test_checks_cursor(app_client, auth_headers, ui_auth_headers):
auth_headers = await register_agent(app_client, ui_auth_headers)
for i in range(4):
ev = make_event(check_id=f"chk-{i}")
await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
)
r1 = await app_client.get("/api/v1/checks?limit=2", headers=auth_headers)
r1 = await app_client.get("/api/v1/checks?limit=2", headers=ui_auth_headers)
p1 = r1.json()
assert len(p1["items"]) == 2
assert p1["next_cursor"]
r2 = await app_client.get(
f"/api/v1/checks?limit=2&cursor={p1['next_cursor']}", headers=auth_headers
f"/api/v1/checks?limit=2&cursor={p1['next_cursor']}", headers=ui_auth_headers
)
p2 = r2.json()
assert len(p2["items"]) == 2
@pytest.mark.asyncio
async def test_checks_rejects_invalid_agent_id_query(app_client, auth_headers):
r = await app_client.get("/api/v1/checks?agent_id=bad%20id", headers=auth_headers)
async def test_checks_rejects_invalid_agent_id_query(app_client, ui_auth_headers):
r = await app_client.get("/api/v1/checks?agent_id=bad%20id", headers=ui_auth_headers)
assert r.status_code == 400
assert r.json()["error"]["code"] == "validation"
@pytest.mark.asyncio
async def test_events_query_rejects_invalid_id_queries(app_client, auth_headers):
r = await app_client.get("/api/v1/events/query?agent_id=bad%20id", headers=auth_headers)
async def test_events_query_rejects_invalid_id_queries(app_client, ui_auth_headers):
r = await app_client.get("/api/v1/events/query?agent_id=bad%20id", headers=ui_auth_headers)
assert r.status_code == 400
assert r.json()["error"]["code"] == "validation"
r = await app_client.get("/api/v1/events/query?check_id=bad%20id", headers=auth_headers)
r = await app_client.get("/api/v1/events/query?check_id=bad%20id", headers=ui_auth_headers)
assert r.status_code == 400
assert r.json()["error"]["code"] == "validation"
@pytest.mark.asyncio
async def test_incidents_cursor(app_client, auth_headers):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
async def test_incidents_cursor(app_client, auth_headers, ui_auth_headers):
auth_headers = await register_agent(app_client, ui_auth_headers)
for i in range(3):
ev = make_event(check_id=f"chk-{i}", status="critical", exit_code=2)
await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
)
r1 = await app_client.get("/api/v1/incidents?limit=2", headers=auth_headers)
r1 = await app_client.get("/api/v1/incidents?limit=2", headers=ui_auth_headers)
p1 = r1.json()
assert len(p1["items"]) == 2
assert p1["next_cursor"]
r2 = await app_client.get(
f"/api/v1/incidents?limit=2&cursor={p1['next_cursor']}", headers=auth_headers
f"/api/v1/incidents?limit=2&cursor={p1['next_cursor']}", headers=ui_auth_headers
)
p2 = r2.json()
assert len(p2["items"]) == 1
@pytest.mark.asyncio
async def test_outbox_cursor(app_client, auth_headers):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
async def test_incidents_default_sort_is_status(app_client, auth_headers, ui_auth_headers):
auth_headers = await register_agent(app_client, ui_auth_headers)
warning = make_event(check_id="warn-check", status="warning", exit_code=1)
critical = make_event(check_id="crit-check", status="critical", exit_code=2)
await app_client.post(
"/api/v1/events",
json={"agent_id": "agent-1", "events": [warning, critical]},
headers=auth_headers,
)
r = await app_client.get("/api/v1/incidents?limit=10", headers=ui_auth_headers)
assert r.status_code == 200
items = r.json()["items"]
assert [i["severity"] for i in items[:2]] == ["critical", "warning"]
@pytest.mark.asyncio
async def test_outbox_cursor(app_client, auth_headers, ui_auth_headers):
auth_headers = await register_agent(app_client, ui_auth_headers)
for i in range(3):
ev = make_event(check_id=f"chk-{i}", status="critical", exit_code=2)
await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
)
r1 = await app_client.get("/api/v1/notifiers/outbox?limit=2", headers=auth_headers)
r1 = await app_client.get("/api/v1/notifiers/outbox?limit=2", headers=ui_auth_headers)
p1 = r1.json()
assert len(p1["items"]) == 2
assert p1["next_cursor"]
@pytest.mark.asyncio
async def test_invalid_cursor_400(app_client, auth_headers):
r = await app_client.get("/api/v1/agents?cursor=$$$bad", headers=auth_headers)
async def test_invalid_cursor_400(app_client, ui_auth_headers):
r = await app_client.get("/api/v1/agents?cursor=$$$bad", headers=ui_auth_headers)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_events_query_invalid_cursor_uuid_400(app_client, ui_auth_headers):
"""PH-010: cursor encodes (received_at, event_id) and event_id must parse
as a UUID before reaching the SQL layer."""
import base64
bad = base64.urlsafe_b64encode(b"2026-01-01T00:00:00+00:00|not-a-uuid").decode().rstrip("=")
r = await app_client.get(f"/api/v1/events/query?cursor={bad}", headers=ui_auth_headers)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_incidents_bidirectional_cursor_round_trip(app_client, auth_headers, ui_auth_headers):
"""PH-review: page1 → next → prev returns page1 with the same items and
order. prev_cursor must be null on the first page."""
auth_headers = await register_agent(app_client, ui_auth_headers)
for i in range(6):
ev = make_event(check_id=f"chk-{i}", status="critical", exit_code=2)
await app_client.post(
"/api/v1/events",
json={"agent_id": "agent-1", "events": [ev]},
headers=auth_headers,
)
r1 = await app_client.get("/api/v1/incidents?limit=3", headers=ui_auth_headers)
p1 = r1.json()
assert len(p1["items"]) == 3
assert p1["next_cursor"]
assert p1.get("prev_cursor") is None # first page
r2 = await app_client.get(
f"/api/v1/incidents?limit=3&cursor={p1['next_cursor']}", headers=ui_auth_headers
)
p2 = r2.json()
assert len(p2["items"]) == 3
assert p2["prev_cursor"]
ids1 = [i["id"] for i in p1["items"]]
ids2 = [i["id"] for i in p2["items"]]
assert set(ids1).isdisjoint(set(ids2))
r_back = await app_client.get(
f"/api/v1/incidents?limit=3&cursor={p2['prev_cursor']}&back=true",
headers=ui_auth_headers,
)
p_back = r_back.json()
assert [i["id"] for i in p_back["items"]] == ids1
# back to first page => prev_cursor must be null again
assert p_back.get("prev_cursor") is None
assert p_back["next_cursor"]
@pytest.mark.asyncio
async def test_outbox_bidirectional_cursor_round_trip(app_client, auth_headers, ui_auth_headers):
auth_headers = await register_agent(app_client, ui_auth_headers)
for i in range(5):
ev = make_event(check_id=f"chk-{i}", status="critical", exit_code=2)
await app_client.post(
"/api/v1/events",
json={"agent_id": "agent-1", "events": [ev]},
headers=auth_headers,
)
r1 = await app_client.get("/api/v1/notifiers/outbox?limit=2", headers=ui_auth_headers)
p1 = r1.json()
assert len(p1["items"]) == 2
assert p1["next_cursor"]
assert p1.get("prev_cursor") is None
r2 = await app_client.get(
f"/api/v1/notifiers/outbox?limit=2&cursor={p1['next_cursor']}",
headers=ui_auth_headers,
)
p2 = r2.json()
assert p2["prev_cursor"]
ids1 = [i["id"] for i in p1["items"]]
ids2 = [i["id"] for i in p2["items"]]
assert set(ids1).isdisjoint(set(ids2))
r_back = await app_client.get(
f"/api/v1/notifiers/outbox?limit=2&cursor={p2['prev_cursor']}&back=true",
headers=ui_auth_headers,
)
p_back = r_back.json()
assert [i["id"] for i in p_back["items"]] == ids1
assert p_back.get("prev_cursor") is None

View File

@@ -67,14 +67,14 @@ async def test_drop_old_partitions_uses_retention(engine, monkeypatch):
@pytest.mark.asyncio
async def test_dedup_prevents_replay_via_ingest(app_client, auth_headers, session):
async def test_dedup_prevents_replay_via_ingest(app_client, ui_auth_headers, session):
from sqlalchemy import func, select
from monlet_server.models import Event
from ._helpers import make_event, make_heartbeat
from ._helpers import make_event, register_agent
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
auth_headers = await register_agent(app_client, ui_auth_headers)
ev = make_event(status="critical", exit_code=2)
r1 = await app_client.post(

View File

@@ -0,0 +1,35 @@
import pytest
from ._helpers import make_event, register_agent
@pytest.mark.asyncio
async def test_system_summary_aggregate(app_client, ui_auth_headers):
auth_headers = await register_agent(app_client, ui_auth_headers, "agent-1")
await register_agent(app_client, ui_auth_headers, "agent-2")
ev = make_event(check_id="c1", status="critical", exit_code=2)
await app_client.post(
"/api/v1/events",
json={"agent_id": "agent-1", "events": [ev]},
headers=auth_headers,
)
r = await app_client.get("/api/v1/system-summary", headers=ui_auth_headers)
assert r.status_code == 200
body = r.json()
assert body["agents_online"] == 2
assert body["agents_offline"] == 0
assert body["checks_critical"] >= 1
assert "generated_at" in body
@pytest.mark.asyncio
async def test_system_summary_requires_auth(app_client):
r = await app_client.get("/api/v1/system-summary")
assert r.status_code == 401
@pytest.mark.asyncio
async def test_outbox_invalid_state_400(app_client, ui_auth_headers):
r = await app_client.get("/api/v1/notifiers/outbox?state=bogus", headers=ui_auth_headers)
assert r.status_code == 400