Harden production workflows and agent admission
This commit is contained in:
@@ -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),
|
||||
)
|
||||
Reference in New Issue
Block a user