agent: implement api
This commit is contained in:
parent
b63d69c81d
commit
33ba248c49
4 changed files with 662 additions and 34 deletions
|
|
@ -1,19 +1,210 @@
|
|||
"""FastAPI application — stub for Plan 04."""
|
||||
"""FastAPI application for the autoscaler daemon."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, NoReturn
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .models import (
|
||||
CapacityHint,
|
||||
ErrorDetail,
|
||||
ErrorResponse,
|
||||
HealthResponse,
|
||||
ReservationPhase,
|
||||
ReservationRequest,
|
||||
ReservationResponse,
|
||||
SlotInfo,
|
||||
SlotState,
|
||||
StateSummary,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import AppConfig
|
||||
from .metrics import MetricsRegistry
|
||||
from .providers.clock import Clock
|
||||
from .providers.haproxy import HAProxyRuntime
|
||||
from .runtime.base import RuntimeAdapter
|
||||
from .state_db import StateDB
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create the FastAPI application.
|
||||
def _parse_required_dt(value: str) -> datetime:
|
||||
return datetime.fromisoformat(value)
|
||||
|
||||
Full implementation in Plan 04.
|
||||
"""
|
||||
|
||||
def _parse_optional_dt(value: str | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
return datetime.fromisoformat(value)
|
||||
|
||||
|
||||
def _resv_to_response(resv: dict) -> ReservationResponse:
|
||||
return ReservationResponse(
|
||||
reservation_id=str(resv["reservation_id"]),
|
||||
phase=ReservationPhase(str(resv["phase"])),
|
||||
slot=resv.get("slot_id"),
|
||||
instance_id=resv.get("instance_id"),
|
||||
system=str(resv["system"]),
|
||||
created_at=_parse_required_dt(str(resv["created_at"])),
|
||||
updated_at=_parse_required_dt(str(resv["updated_at"])),
|
||||
expires_at=_parse_required_dt(str(resv["expires_at"])),
|
||||
released_at=_parse_optional_dt(resv.get("released_at")),
|
||||
)
|
||||
|
||||
|
||||
def _slot_to_info(slot: dict) -> SlotInfo:
|
||||
return SlotInfo(
|
||||
slot_id=str(slot["slot_id"]),
|
||||
system=str(slot["system"]),
|
||||
state=SlotState(str(slot["state"])),
|
||||
instance_id=slot.get("instance_id"),
|
||||
instance_ip=slot.get("instance_ip"),
|
||||
lease_count=int(slot["lease_count"]),
|
||||
last_state_change=_parse_required_dt(str(slot["last_state_change"])),
|
||||
)
|
||||
|
||||
|
||||
def _error_response(
|
||||
request: Request,
|
||||
status_code: int,
|
||||
code: str,
|
||||
message: str,
|
||||
retryable: bool = False,
|
||||
) -> NoReturn:
|
||||
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
|
||||
payload = ErrorResponse(
|
||||
error=ErrorDetail(code=code, message=message, retryable=retryable),
|
||||
request_id=request_id,
|
||||
)
|
||||
raise HTTPException(status_code=status_code, detail=payload.model_dump(mode="json"))
|
||||
|
||||
|
||||
def create_app(
|
||||
db: StateDB,
|
||||
config: AppConfig,
|
||||
clock: Clock,
|
||||
metrics: MetricsRegistry,
|
||||
runtime: RuntimeAdapter | None = None,
|
||||
haproxy: HAProxyRuntime | None = None,
|
||||
scheduler_running: Callable[[], bool] | None = None,
|
||||
reconciler_running: Callable[[], bool] | None = None,
|
||||
) -> FastAPI:
|
||||
"""Create the FastAPI application."""
|
||||
app = FastAPI(title="nix-builder-autoscaler", version="0.1.0")
|
||||
|
||||
@app.get("/health/live")
|
||||
def health_live() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
app.state.db = db
|
||||
app.state.config = config
|
||||
app.state.clock = clock
|
||||
app.state.metrics = metrics
|
||||
app.state.runtime = runtime
|
||||
app.state.haproxy = haproxy
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_id_middleware(request: Request, call_next: Callable) -> Response:
|
||||
request.state.request_id = str(uuid.uuid4())
|
||||
response = await call_next(request)
|
||||
response.headers["x-request-id"] = request.state.request_id
|
||||
return response
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
detail = exc.detail
|
||||
if isinstance(detail, dict) and "error" in detail and "request_id" in detail:
|
||||
return JSONResponse(status_code=exc.status_code, content=detail)
|
||||
|
||||
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
|
||||
payload = ErrorResponse(
|
||||
error=ErrorDetail(
|
||||
code="http_error",
|
||||
message=str(detail) if detail else "Request failed",
|
||||
retryable=False,
|
||||
),
|
||||
request_id=request_id,
|
||||
)
|
||||
return JSONResponse(status_code=exc.status_code, content=payload.model_dump(mode="json"))
|
||||
|
||||
@app.post("/v1/reservations", response_model=ReservationResponse)
|
||||
def create_reservation(body: ReservationRequest) -> ReservationResponse:
|
||||
resv = db.create_reservation(
|
||||
body.system,
|
||||
body.reason,
|
||||
body.build_id,
|
||||
config.capacity.reservation_ttl_seconds,
|
||||
)
|
||||
return _resv_to_response(resv)
|
||||
|
||||
@app.get("/v1/reservations/{reservation_id}", response_model=ReservationResponse)
|
||||
def get_reservation(reservation_id: str, request: Request) -> ReservationResponse:
|
||||
resv = db.get_reservation(reservation_id)
|
||||
if resv is None:
|
||||
_error_response(request, 404, "not_found", "Reservation not found")
|
||||
return _resv_to_response(resv)
|
||||
|
||||
@app.post("/v1/reservations/{reservation_id}/release", response_model=ReservationResponse)
|
||||
def release_reservation(reservation_id: str, request: Request) -> ReservationResponse:
|
||||
resv = db.release_reservation(reservation_id)
|
||||
if resv is None:
|
||||
_error_response(request, 404, "not_found", "Reservation not found")
|
||||
return _resv_to_response(resv)
|
||||
|
||||
@app.get("/v1/reservations", response_model=list[ReservationResponse])
|
||||
def list_reservations(
|
||||
phase: ReservationPhase | None = None,
|
||||
) -> list[ReservationResponse]:
|
||||
reservations = db.list_reservations(phase)
|
||||
return [_resv_to_response(resv) for resv in reservations]
|
||||
|
||||
@app.get("/v1/slots", response_model=list[SlotInfo])
|
||||
def list_slots() -> list[SlotInfo]:
|
||||
slots = db.list_slots()
|
||||
return [_slot_to_info(slot) for slot in slots]
|
||||
|
||||
@app.get("/v1/state/summary", response_model=StateSummary)
|
||||
def state_summary() -> StateSummary:
|
||||
summary = db.get_state_summary()
|
||||
return StateSummary.model_validate(summary)
|
||||
|
||||
@app.post("/v1/hints/capacity")
|
||||
def capacity_hint(hint: CapacityHint) -> dict[str, str]:
|
||||
log.info(
|
||||
"capacity_hint",
|
||||
extra={
|
||||
"builder": hint.builder,
|
||||
"queued": hint.queued,
|
||||
"running": hint.running,
|
||||
"system": hint.system,
|
||||
"timestamp": hint.timestamp.isoformat(),
|
||||
},
|
||||
)
|
||||
return {"status": "accepted"}
|
||||
|
||||
@app.get("/health/live", response_model=HealthResponse)
|
||||
def health_live() -> HealthResponse:
|
||||
return HealthResponse(status="ok")
|
||||
|
||||
@app.get("/health/ready", response_model=HealthResponse)
|
||||
def health_ready() -> HealthResponse:
|
||||
if scheduler_running is not None and not scheduler_running():
|
||||
return JSONResponse( # type: ignore[return-value]
|
||||
status_code=503,
|
||||
content=HealthResponse(status="degraded").model_dump(mode="json"),
|
||||
)
|
||||
if reconciler_running is not None and not reconciler_running():
|
||||
return JSONResponse( # type: ignore[return-value]
|
||||
status_code=503,
|
||||
content=HealthResponse(status="degraded").model_dump(mode="json"),
|
||||
)
|
||||
return HealthResponse(status="ok")
|
||||
|
||||
@app.get("/metrics")
|
||||
def metrics_endpoint() -> Response:
|
||||
return Response(content=metrics.render(), media_type="text/plain")
|
||||
|
||||
return app
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue