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