feat(node): pair onboard computers with the Core fleet through UI
This commit is contained in:
+25
-1
@@ -112,6 +112,7 @@ from k1link.simulation.projects import SimulationProjectService, SimulationProje
|
||||
from k1link.web.advanced_laboratory_api import build_advanced_laboratory_router
|
||||
from k1link.web.artifact_health_api import build_artifact_health_router
|
||||
from k1link.web.compute_contour_api import build_compute_contour_router
|
||||
from k1link.web.fleet_api import router as fleet_router
|
||||
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
||||
from k1link.web.e30_engineering_api import build_e30_engineering_router
|
||||
from k1link.web.e30_human_review_api import build_e30_human_review_router
|
||||
@@ -897,7 +898,24 @@ async def _recorded_blueprint_resource_reaper() -> None:
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
async def app_lifespan(application: FastAPI) -> AsyncIterator[None]:
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from k1link.fleet.registry import FleetRegistry
|
||||
|
||||
fleet = None
|
||||
application.state.fleet = None
|
||||
try:
|
||||
fleet = FleetRegistry(session_store.data_dir / "fleet")
|
||||
fleet.start()
|
||||
application.state.fleet = fleet
|
||||
except (OSError, ValueError, sqlite3.Error):
|
||||
# Fail closed for pairing without taking down unrelated operator work.
|
||||
if fleet is not None:
|
||||
fleet.close()
|
||||
fleet = None
|
||||
logging.getLogger(__name__).error("Fleet trust storage unavailable; pairing disabled")
|
||||
reconciler: asyncio.Task[None] | None = None
|
||||
publication_reconciler: asyncio.Task[None] | None = None
|
||||
blueprint_reaper: asyncio.Task[None] | None = None
|
||||
@@ -920,6 +938,9 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
finally:
|
||||
from k1link.viewer.recorded import recorded_blueprint_sessions
|
||||
|
||||
application.state.fleet = None
|
||||
if fleet is not None:
|
||||
await asyncio.to_thread(fleet.close)
|
||||
if blueprint_reaper is not None:
|
||||
blueprint_reaper.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
@@ -950,6 +971,9 @@ app = FastAPI(
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1_024, compresslevel=5)
|
||||
|
||||
|
||||
app.include_router(fleet_router)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def request_validation_error_handler(
|
||||
_: Request,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Operator-only fleet admission; the separate private mTLS listener is in fleet."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from typing import Annotated
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
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("")
|
||||
def fleet_list(response: Response, fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return fleet.listing()
|
||||
|
||||
|
||||
@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
|
||||
Reference in New Issue
Block a user