Files
crucible-agent-build-fastap…/app/main.py

108 lines
3.5 KiB
Python

import logging
from contextlib import asynccontextmanager
from uuid import UUID
from fastapi import FastAPI, HTTPException, Response, status
from .checker import check_url
from .config import get_settings
from .logging import log_event
from .models import CheckResult, CheckSnapshot, Monitor, MonitorInput
from .security import UnsafeDestination
from .store import MonitorStore, StoreFullError
settings = get_settings()
logging.basicConfig(level=settings.log_level, format="%(message)s")
logger = logging.getLogger("endpoint_monitor.api")
@asynccontextmanager
async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
app.state.store = MonitorStore(settings.max_monitors)
log_event(logger, "service_started", storage="process-local-memory")
yield
app = FastAPI(title="Endpoint Monitor", version="0.1.0", lifespan=lifespan)
def store() -> MonitorStore:
return app.state.store
def missing() -> HTTPException:
return HTTPException(status_code=404, detail="monitor not found")
@app.get("/healthz")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/readyz")
async def ready() -> dict[str, str]:
return {"status": "ready", "storage": "process-local-memory"}
@app.post("/monitors", response_model=Monitor, status_code=201)
async def create_monitor(data: MonitorInput) -> Monitor:
try:
return await store().create(data)
except StoreFullError as exc:
raise HTTPException(status_code=503, detail="monitor capacity reached") from exc
@app.get("/monitors", response_model=list[Monitor])
async def list_monitors() -> list[Monitor]:
return await store().list()
@app.get("/monitors/{monitor_id}", response_model=Monitor)
async def get_monitor(monitor_id: UUID) -> Monitor:
item = await store().get(monitor_id)
if item is None:
raise missing()
return item
@app.put("/monitors/{monitor_id}", response_model=Monitor)
async def replace_monitor(monitor_id: UUID, data: MonitorInput) -> Monitor:
item = await store().replace(monitor_id, data)
if item is None:
raise missing()
return item
@app.delete("/monitors/{monitor_id}", status_code=204)
async def delete_monitor(monitor_id: UUID) -> Response:
if not await store().delete(monitor_id):
raise missing()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@app.get("/monitors/{monitor_id}/status", response_model=CheckSnapshot)
async def current_status(monitor_id: UUID) -> CheckSnapshot:
item = await store().get(monitor_id)
if item is None:
raise missing()
return item.status
@app.post("/monitors/{monitor_id}/check", response_model=CheckResult)
async def check_monitor(monitor_id: UUID) -> CheckResult:
item = await store().get(monitor_id)
if item is None:
raise missing()
timeout = item.timeout_seconds or settings.request_timeout_seconds
try:
snapshot = await check_url(str(item.url), timeout, settings)
except UnsafeDestination as exc:
snapshot = CheckSnapshot(state="error", checked_at=__import__(
"datetime").datetime.now(__import__("datetime").UTC), error=str(exc))
await store().set_status(item.id, item.revision, snapshot)
raise HTTPException(status_code=400, detail=str(exc)) from exc
updated = await store().set_status(item.id, item.revision, snapshot)
if updated is None:
raise HTTPException(status_code=409, detail="monitor changed during check")
return CheckResult(monitor_id=item.id, **snapshot.model_dump())