Harden production workflows and agent admission

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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