feat(host-protocol): renew and complete leases
This commit is contained in:
@@ -2,10 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import timedelta
|
||||
from time import monotonic
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from cloud.auth import (
|
||||
AuthProvider,
|
||||
@@ -17,6 +19,11 @@ from cloud.internal_api.models import (
|
||||
ClaimResponse,
|
||||
HeartbeatRequest,
|
||||
HeartbeatResponse,
|
||||
LeaseRenewalRequest,
|
||||
LeaseRenewalResponse,
|
||||
StaleLeaseConflict,
|
||||
TerminalResultRequest,
|
||||
TerminalResultResponse,
|
||||
)
|
||||
from core.models import Device, utc_now
|
||||
|
||||
@@ -30,10 +37,13 @@ def create_internal_router(
|
||||
auth_provider: AuthProvider,
|
||||
version_prefix: str = "/internal/v1",
|
||||
claim_poll_interval_seconds: float = 0.1,
|
||||
lease_duration_seconds: float = 60.0,
|
||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
||||
) -> APIRouter:
|
||||
if claim_poll_interval_seconds <= 0:
|
||||
raise ValueError("claim_poll_interval_seconds must be greater than zero")
|
||||
if lease_duration_seconds <= 0:
|
||||
raise ValueError("lease_duration_seconds must be greater than zero")
|
||||
router = APIRouter(prefix=version_prefix, tags=["host-agent"])
|
||||
|
||||
def authorize_host(request: Request, host_id: str) -> None:
|
||||
@@ -117,9 +127,102 @@ def create_internal_router(
|
||||
return ClaimResponse(timed_out=True)
|
||||
await sleep(min(claim_poll_interval_seconds, remaining))
|
||||
|
||||
@router.post(
|
||||
"/hosts/{host_id}/assignments/{task_id}/renew",
|
||||
response_model=LeaseRenewalResponse,
|
||||
responses={status.HTTP_409_CONFLICT: {"model": StaleLeaseConflict}},
|
||||
)
|
||||
def renew_assignment(
|
||||
host_id: str,
|
||||
task_id: str,
|
||||
payload: LeaseRenewalRequest,
|
||||
request: Request,
|
||||
):
|
||||
authorize_host(request, host_id)
|
||||
_validate_assignment_identity(
|
||||
host_id=host_id,
|
||||
task_id=task_id,
|
||||
payload_host_id=payload.host_id,
|
||||
payload_task_id=payload.task_id,
|
||||
)
|
||||
now = utc_now()
|
||||
lease_expires_at = now + timedelta(seconds=lease_duration_seconds)
|
||||
renewal_status = pool.store.renew_lease(
|
||||
task_id=task_id,
|
||||
attempt=payload.attempt,
|
||||
lease_id=payload.lease_id,
|
||||
host_id=host_id,
|
||||
lease_expires_at=lease_expires_at,
|
||||
now=now,
|
||||
)
|
||||
if renewal_status == "not_found":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="assignment not found",
|
||||
)
|
||||
if renewal_status != "renewed":
|
||||
return _stale_lease_conflict("assignment lease is stale or expired")
|
||||
return LeaseRenewalResponse(
|
||||
status="renewed",
|
||||
lease_expires_at=lease_expires_at,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/hosts/{host_id}/assignments/{task_id}/result",
|
||||
response_model=TerminalResultResponse,
|
||||
responses={status.HTTP_409_CONFLICT: {"model": StaleLeaseConflict}},
|
||||
)
|
||||
def report_result(
|
||||
host_id: str,
|
||||
task_id: str,
|
||||
payload: TerminalResultRequest,
|
||||
request: Request,
|
||||
):
|
||||
authorize_host(request, host_id)
|
||||
_validate_assignment_identity(
|
||||
host_id=host_id,
|
||||
task_id=task_id,
|
||||
payload_host_id=payload.host_id,
|
||||
payload_task_id=payload.task_id,
|
||||
)
|
||||
result_status = pool.store.record_task_result(
|
||||
task_id=task_id,
|
||||
attempt=payload.attempt,
|
||||
lease_id=payload.lease_id,
|
||||
host_id=host_id,
|
||||
status=payload.status,
|
||||
failure_reason=payload.failure_reason,
|
||||
terminal_result=payload.result,
|
||||
completed_at=utc_now(),
|
||||
)
|
||||
if result_status == "conflict":
|
||||
return _stale_lease_conflict("assignment lease is stale or superseded")
|
||||
return TerminalResultResponse(status=result_status)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _validate_assignment_identity(
|
||||
*,
|
||||
host_id: str,
|
||||
task_id: str,
|
||||
payload_host_id: str,
|
||||
payload_task_id: str,
|
||||
) -> None:
|
||||
if payload_host_id != host_id or payload_task_id != task_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="assignment identity must match the request path",
|
||||
)
|
||||
|
||||
|
||||
def _stale_lease_conflict(detail: str) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
content=StaleLeaseConflict(detail=detail).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
def _validate_snapshot(
|
||||
pool: DevicePool,
|
||||
*,
|
||||
|
||||
@@ -70,3 +70,8 @@ class TerminalResultRequest(BaseModel):
|
||||
|
||||
class TerminalResultResponse(BaseModel):
|
||||
status: Literal["recorded", "already_recorded"]
|
||||
|
||||
|
||||
class StaleLeaseConflict(BaseModel):
|
||||
code: Literal["stale_lease"] = "stale_lease"
|
||||
detail: str
|
||||
|
||||
Reference in New Issue
Block a user