decomposer: generate deliverable files for Define the service contract and project architecture for the FastAPI endpoint monitoring service.; Implement the typed monitor CRUD API and concurrency-safe in-memory state according to the service design.; Implement secure on-demand endpoint checks with status updates, latency measurement, robust error handling, and redacted structured logs.; Add operational API endpoints and environment-driven runtime configuration to the monitoring service.; Create automated tests for the monitoring service.; Package the service with Docker and developer documentation.; Validate the complete project.
Some checks failed
ci / test (push) Has been cancelled
Some checks failed
ci / test (push) Has been cancelled
This commit is contained in:
170
app/main.py
170
app/main.py
@@ -1,107 +1,79 @@
|
|||||||
import logging
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Response, status
|
from fastapi import FastAPI, HTTPException, Response, status
|
||||||
|
|
||||||
from .checker import check_url
|
from app.checker import EndpointChecker
|
||||||
from .config import get_settings
|
from app.config import Settings, get_settings
|
||||||
from .logging import log_event
|
from app.logging_config import configure_logging
|
||||||
from .models import CheckResult, CheckSnapshot, Monitor, MonitorInput
|
from app.models import Health, Monitor, MonitorCreate, MonitorUpdate, StatusSnapshot
|
||||||
from .security import UnsafeDestination
|
from app.store import MonitorStore
|
||||||
from .store import MonitorStore, StoreFullError
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
logging.basicConfig(level=settings.log_level, format="%(message)s")
|
|
||||||
logger = logging.getLogger("endpoint_monitor.api")
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
def create_app(
|
||||||
async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
|
settings: Settings | None = None, store: MonitorStore | None = None,
|
||||||
app.state.store = MonitorStore(settings.max_monitors)
|
checker: EndpointChecker | None = None,
|
||||||
log_event(logger, "service_started", storage="process-local-memory")
|
) -> FastAPI:
|
||||||
yield
|
config = settings or get_settings()
|
||||||
|
configure_logging(config.log_level)
|
||||||
|
state = store or MonitorStore()
|
||||||
|
service = checker or EndpointChecker(config)
|
||||||
|
api = FastAPI(title=config.app_name, version="1.0.0")
|
||||||
|
|
||||||
|
async def existing(monitor_id: UUID) -> Monitor:
|
||||||
|
monitor = await state.get(monitor_id)
|
||||||
|
if monitor is None:
|
||||||
|
raise HTTPException(status_code=404, detail="monitor not found")
|
||||||
|
return monitor
|
||||||
|
|
||||||
|
@api.post("/monitors", response_model=Monitor, status_code=201)
|
||||||
|
async def create_monitor(body: MonitorCreate) -> Monitor:
|
||||||
|
return await state.create(body)
|
||||||
|
|
||||||
|
@api.get("/monitors", response_model=list[Monitor])
|
||||||
|
async def list_monitors() -> list[Monitor]:
|
||||||
|
return await state.list()
|
||||||
|
|
||||||
|
@api.get("/monitors/{monitor_id}", response_model=Monitor)
|
||||||
|
async def get_monitor(monitor_id: UUID) -> Monitor:
|
||||||
|
return await existing(monitor_id)
|
||||||
|
|
||||||
|
@api.put("/monitors/{monitor_id}", response_model=Monitor)
|
||||||
|
async def update_monitor(monitor_id: UUID, body: MonitorUpdate) -> Monitor:
|
||||||
|
monitor = await state.update(monitor_id, body)
|
||||||
|
if monitor is None:
|
||||||
|
raise HTTPException(status_code=404, detail="monitor not found")
|
||||||
|
return monitor
|
||||||
|
|
||||||
|
@api.delete("/monitors/{monitor_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_monitor(monitor_id: UUID) -> Response:
|
||||||
|
if not await state.delete(monitor_id):
|
||||||
|
raise HTTPException(status_code=404, detail="monitor not found")
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
@api.post("/monitors/{monitor_id}/check", response_model=StatusSnapshot)
|
||||||
|
async def check_monitor(monitor_id: UUID) -> StatusSnapshot:
|
||||||
|
pending = await state.begin_check(monitor_id)
|
||||||
|
if pending is None:
|
||||||
|
raise HTTPException(status_code=404, detail="monitor not found")
|
||||||
|
monitor, revision = pending
|
||||||
|
result = await service.check(str(monitor.id), str(monitor.url))
|
||||||
|
await state.finish_check(monitor_id, revision, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@api.get("/monitors/{monitor_id}/status", response_model=StatusSnapshot)
|
||||||
|
async def current_status(monitor_id: UUID) -> StatusSnapshot:
|
||||||
|
return (await existing(monitor_id)).status
|
||||||
|
|
||||||
|
@api.get("/health/live", response_model=Health)
|
||||||
|
async def liveness() -> Health:
|
||||||
|
return Health(status="ok")
|
||||||
|
|
||||||
|
@api.get("/health/ready", response_model=Health)
|
||||||
|
async def readiness() -> Health:
|
||||||
|
await state.list()
|
||||||
|
return Health(status="ready")
|
||||||
|
|
||||||
|
return api
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Endpoint Monitor", version="0.1.0", lifespan=lifespan)
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
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())
|
|
||||||
|
|||||||
Reference in New Issue
Block a user