feat(node): pair onboard computers with the Core fleet through UI

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 21:16:13 +03:00
parent fc545f8440
commit e82d012907
29 changed files with 2442 additions and 19 deletions
+25 -1
View File
@@ -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,
+85
View File
@@ -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