Harden production workflows and agent admission
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;")
|
||||
|
||||
30
server/monlet_server/agent_auth.py
Normal file
30
server/monlet_server/agent_auth.py
Normal 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
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
43
server/monlet_server/api/system_summary.py
Normal file
43
server/monlet_server/api/system_summary.py
Normal 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),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
354
server/tests/test_agent_admission.py
Normal file
354
server/tests/test_agent_admission.py
Normal 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]
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
35
server/tests/test_system_summary.py
Normal file
35
server/tests/test_system_summary.py
Normal 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
|
||||
Reference in New Issue
Block a user