Files
NODEDC_MISSION_CORE/src/k1link/web/fleet_api.py
T

203 lines
7.0 KiB
Python

"""Operator-only fleet admission; the separate private mTLS listener is in fleet."""
from __future__ import annotations
import asyncio
import ipaddress
import json
from contextlib import suppress
from typing import Annotated
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, Field
from k1link.fleet.registry import FleetRegistry
from k1link.fleet.trust import PairingError
def local_operator(request: Request) -> FleetRegistry:
try:
peer = ipaddress.ip_address(request.client.host)
host = urlsplit(f"http://{request.headers.get('host', '')}")
if not peer.is_loopback or host.hostname not in ("127.0.0.1", "localhost", "::1"):
raise ValueError
origin = request.headers.get("origin")
if origin and origin != f"http://{request.headers['host']}":
raise ValueError
if request.headers.get("sec-fetch-site") == "cross-site":
raise ValueError
except (ValueError, AttributeError, KeyError):
raise HTTPException(403, "Откройте Mission Core на компьютере оператора.") from None
registry = getattr(request.app.state, "fleet", None)
if registry is None:
raise HTTPException(503, "Реестр аппаратов недоступен. Повторите подключение.")
return registry
class PreviewRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
code: str = Field(min_length=1, max_length=4096)
class AddRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
preview_id: str = Field(min_length=43, max_length=43)
name: str = Field(min_length=1, max_length=80)
platform: str = Field(pattern="^(ugv|uav|stationary|other)$")
router = APIRouter(prefix="/api/v1/fleet", tags=["fleet"])
@router.get("/{vehicle_id}/monitor")
def board_monitor(vehicle_id: str, response: Response,
fleet: Annotated[FleetRegistry, Depends(local_operator)],
metric: str = "cpu.usage", window: int = 900, end: float | None = None):
response.headers["Cache-Control"] = "no-store"
try:
with fleet.lock:
node_id = fleet.find(vehicle_id)["node_id"]
return fleet.monitor.query(node_id, metric, window, end)
except PairingError as error:
raise HTTPException(404, str(error)) from None
except ValueError:
raise HTTPException(400, "Выберите доступный период и показатель.") from None
@router.get("")
def fleet_list(response: Response, fleet: Annotated[FleetRegistry, Depends(local_operator)]):
response.headers["Cache-Control"] = "no-store"
return fleet.listing()
@router.get("/events")
async def fleet_events(fleet: Annotated[FleetRegistry, Depends(local_operator)]):
async def stream():
queue, close = fleet.events.subscribe()
try:
while not fleet.stop.is_set():
# Full replacement snapshots recover missed events/reconnections.
# The timeout updates link expiry locally; it never queries a Node.
value = await asyncio.to_thread(fleet.listing)
yield "retry: 3000\ndata: " + json.dumps(value) + "\n\n"
with suppress(TimeoutError):
await asyncio.wait_for(queue.get(), timeout=5)
finally:
close()
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-store",
"X-Accel-Buffering": "no",
},
)
@router.post("/preview")
def fleet_preview(
body: PreviewRequest,
response: Response,
fleet: Annotated[FleetRegistry, Depends(local_operator)],
):
response.headers["Cache-Control"] = "no-store"
try:
return fleet.preview(body.code)
except PairingError as error:
raise HTTPException(409, str(error)) from None
@router.post("")
def fleet_add(
body: AddRequest, response: Response, fleet: Annotated[FleetRegistry, Depends(local_operator)]
):
response.headers["Cache-Control"] = "no-store"
try:
return fleet.add(body.preview_id, body.name, body.platform)
except PairingError as error:
raise HTTPException(409, str(error)) from None
@router.delete("/{vehicle_id}")
def fleet_revoke(vehicle_id: str, fleet: Annotated[FleetRegistry, Depends(local_operator)]):
try:
return fleet.revoke(vehicle_id)
except PairingError as error:
raise HTTPException(404, str(error)) from None
@router.post("/{vehicle_id}/devices/operations")
def sensor_command(
vehicle_id: str, body: dict, fleet: Annotated[FleetRegistry, Depends(local_operator)]
):
from k1link.fleet.sensors import submit
try:
return submit(fleet, vehicle_id, body)
except (PairingError, ValueError) as error:
raise HTTPException(409, str(error)) from None
@router.get("/{vehicle_id}/devices/operations/{operation_id}")
def sensor_operation(
vehicle_id: str, operation_id: str, fleet: Annotated[FleetRegistry, Depends(local_operator)]
):
from k1link.fleet.sensors import operation
try:
return operation(fleet, vehicle_id, operation_id)
except PairingError as error:
raise HTTPException(404, str(error)) from None
@router.get("/{vehicle_id}/devices/enrollment")
def enrollment_state(
vehicle_id: str, response: Response, fleet: Annotated[FleetRegistry, Depends(local_operator)]
):
response.headers["Cache-Control"] = "no-store"
try:
with fleet.lock:
row = fleet.find(vehicle_id)
return {
**row.get("device_enrollment", {"available": False}),
"node_id": row["node_id"],
"name": row["name"],
"fresh": fleet.public(row)["connectivity"] == "online",
}
except PairingError as error:
raise HTTPException(404, str(error)) from None
@router.post("/{vehicle_id}/devices/enrollment/operations")
def enrollment_submit(
vehicle_id: str,
body: dict,
response: Response,
fleet: Annotated[FleetRegistry, Depends(local_operator)],
):
response.headers["Cache-Control"] = "no-store"
try:
return fleet.device_enrollment.submit(fleet, vehicle_id, body)
except (PairingError, ValueError, TypeError):
# Validation diagnostics must never echo a supplied credential.
raise HTTPException(
409, "Запрос подключения не принят. Обновите БК и проверьте параметры сети."
) from None
@router.get("/{vehicle_id}/devices/enrollment/operations/{operation_id}")
def enrollment_operation(
vehicle_id: str,
operation_id: str,
response: Response,
fleet: Annotated[FleetRegistry, Depends(local_operator)],
):
response.headers["Cache-Control"] = "no-store"
try:
return fleet.device_enrollment.operation(fleet, vehicle_id, operation_id)
except PairingError as error:
raise HTTPException(404, str(error)) from None