feat(simulation): add Polygon live worker gateway
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Final
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import APIRouter, Header, HTTPException, Query
|
||||
from fastapi import Path as PathParameter
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from k1link.simulation import (
|
||||
QualificationRun,
|
||||
@@ -14,10 +18,22 @@ from k1link.simulation import (
|
||||
QualificationRunNotFoundError,
|
||||
QualificationRunStore,
|
||||
)
|
||||
from k1link.simulation.worker_gateway import (
|
||||
STATUS_SCHEMA,
|
||||
PolygonWorkerGateway,
|
||||
SimulationWorkerGatewayError,
|
||||
SimulationWorkerRejectedError,
|
||||
SimulationWorkerUnavailableError,
|
||||
UnixSocketWorkerGateway,
|
||||
)
|
||||
|
||||
POLYGON_RUNS_ROOT_ENV: Final = "MISSIONCORE_POLYGON_RUNS_ROOT"
|
||||
POLYGON_WORKER_SOCKET_ENV: Final = "MISSIONCORE_POLYGON_WORKER_SOCKET"
|
||||
POLYGON_WORKER_CONTROL_ENV: Final = "MISSIONCORE_POLYGON_WORKER_CONTROL"
|
||||
MISSION_CORE_COMMIT_ENV: Final = "MISSIONCORE_COMMIT"
|
||||
CATALOG_SCHEMA: Final = "missioncore.polygon-run-catalog/v1"
|
||||
DETAIL_SCHEMA: Final = "missioncore.polygon-run-detail/v1"
|
||||
COMMIT_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$")
|
||||
READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = (
|
||||
"UI-0 публикует только квалификационные доказательства; "
|
||||
"lifecycle-операции и команды отсутствуют.",
|
||||
@@ -27,6 +43,15 @@ READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = (
|
||||
)
|
||||
|
||||
RootProvider = Callable[[], Path | None]
|
||||
WorkerProvider = Callable[[], PolygonWorkerGateway | None]
|
||||
ControlProvider = Callable[[], bool]
|
||||
CommitProvider = Callable[[], str | None]
|
||||
|
||||
|
||||
class StartStockRoverRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
scenario_id: str
|
||||
|
||||
|
||||
def configured_polygon_runs_root() -> Path | None:
|
||||
@@ -36,9 +61,31 @@ def configured_polygon_runs_root() -> Path | None:
|
||||
return Path(raw.strip()).expanduser()
|
||||
|
||||
|
||||
def configured_polygon_worker() -> PolygonWorkerGateway | None:
|
||||
raw = os.environ.get(POLYGON_WORKER_SOCKET_ENV)
|
||||
if raw is None or not raw.strip():
|
||||
return None
|
||||
return UnixSocketWorkerGateway(Path(raw.strip()).expanduser())
|
||||
|
||||
|
||||
def configured_polygon_control() -> bool:
|
||||
return os.environ.get(POLYGON_WORKER_CONTROL_ENV, "").strip() == "internal-virtual-only"
|
||||
|
||||
|
||||
def configured_mission_core_commit() -> str | None:
|
||||
raw = os.environ.get(MISSION_CORE_COMMIT_ENV)
|
||||
if raw is None:
|
||||
return None
|
||||
normalized = raw.strip()
|
||||
return normalized if COMMIT_PATTERN.fullmatch(normalized) else None
|
||||
|
||||
|
||||
def build_polygon_router(
|
||||
*,
|
||||
root_provider: RootProvider = configured_polygon_runs_root,
|
||||
worker_provider: WorkerProvider = configured_polygon_worker,
|
||||
control_provider: ControlProvider = configured_polygon_control,
|
||||
commit_provider: CommitProvider = configured_mission_core_commit,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/polygon", tags=["polygon"])
|
||||
|
||||
@@ -111,6 +158,98 @@ def build_polygon_router(
|
||||
"limitations": list(READ_ONLY_LIMITATIONS),
|
||||
}
|
||||
|
||||
@router.get("/worker")
|
||||
def get_polygon_worker() -> dict[str, Any]:
|
||||
gateway = worker_provider()
|
||||
if gateway is None:
|
||||
return _unavailable_worker_status()
|
||||
try:
|
||||
status = gateway.status()
|
||||
except SimulationWorkerGatewayError:
|
||||
return _unavailable_worker_status()
|
||||
return {
|
||||
**status,
|
||||
"control_available": bool(status["control_available"] and control_provider()),
|
||||
}
|
||||
|
||||
@router.get("/worker/live")
|
||||
def get_polygon_worker_live() -> dict[str, Any]:
|
||||
gateway = _required_worker(worker_provider)
|
||||
try:
|
||||
return gateway.live()
|
||||
except SimulationWorkerUnavailableError as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Simulation Worker недоступен.",
|
||||
) from exc
|
||||
except SimulationWorkerRejectedError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except SimulationWorkerGatewayError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Simulation Worker вернул некорректное состояние.",
|
||||
) from exc
|
||||
|
||||
@router.post("/worker/runs")
|
||||
def start_polygon_worker_run(
|
||||
request: StartStockRoverRequest,
|
||||
idempotency_key: Annotated[
|
||||
str,
|
||||
Header(alias="Idempotency-Key", min_length=1, max_length=160),
|
||||
],
|
||||
) -> dict[str, Any]:
|
||||
if request.scenario_id != "stock-rover-ackermann":
|
||||
raise HTTPException(status_code=422, detail="Сценарий Полигона не поддерживается.")
|
||||
gateway, commit = _control_context(
|
||||
worker_provider,
|
||||
control_provider,
|
||||
commit_provider,
|
||||
)
|
||||
run_id = _new_run_id(commit)
|
||||
try:
|
||||
return gateway.start(
|
||||
run_id=run_id,
|
||||
mission_core_commit=commit,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
except SimulationWorkerUnavailableError as exc:
|
||||
raise HTTPException(status_code=503, detail="Simulation Worker недоступен.") from exc
|
||||
except SimulationWorkerRejectedError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except SimulationWorkerGatewayError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Simulation Worker не подтвердил запуск.",
|
||||
) from exc
|
||||
|
||||
@router.post("/worker/runs/{run_id}/stop")
|
||||
def stop_polygon_worker_run(
|
||||
run_id: Annotated[
|
||||
str,
|
||||
PathParameter(pattern=r"^[a-z0-9][a-z0-9-]{0,63}$"),
|
||||
],
|
||||
idempotency_key: Annotated[
|
||||
str,
|
||||
Header(alias="Idempotency-Key", min_length=1, max_length=160),
|
||||
],
|
||||
) -> dict[str, Any]:
|
||||
gateway, _ = _control_context(
|
||||
worker_provider,
|
||||
control_provider,
|
||||
commit_provider,
|
||||
)
|
||||
try:
|
||||
return gateway.stop(run_id=run_id, idempotency_key=idempotency_key)
|
||||
except SimulationWorkerUnavailableError as exc:
|
||||
raise HTTPException(status_code=503, detail="Simulation Worker недоступен.") from exc
|
||||
except SimulationWorkerRejectedError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except SimulationWorkerGatewayError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Simulation Worker не подтвердил остановку.",
|
||||
) from exc
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@@ -155,3 +294,62 @@ def _run_summary(run: QualificationRun) -> dict[str, Any]:
|
||||
"provider_ids": [provider.identifier for provider in run.providers],
|
||||
"artifact_count": len(run.artifacts),
|
||||
}
|
||||
|
||||
|
||||
def _required_worker(provider: WorkerProvider) -> PolygonWorkerGateway:
|
||||
gateway = provider()
|
||||
if gateway is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Simulation Worker не зарегистрирован на этом экземпляре Mission Core.",
|
||||
)
|
||||
return gateway
|
||||
|
||||
|
||||
def _control_context(
|
||||
worker_provider: WorkerProvider,
|
||||
control_provider: ControlProvider,
|
||||
commit_provider: CommitProvider,
|
||||
) -> tuple[PolygonWorkerGateway, str]:
|
||||
if not control_provider():
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Lifecycle Полигона отключён на этом экземпляре Mission Core.",
|
||||
)
|
||||
gateway = _required_worker(worker_provider)
|
||||
commit = commit_provider()
|
||||
if commit is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Mission Core не привязан к точной Git-ревизии.",
|
||||
)
|
||||
return gateway, commit
|
||||
|
||||
|
||||
def _unavailable_worker_status() -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": STATUS_SCHEMA,
|
||||
"worker_id": "mission-gpu-s1",
|
||||
"transport": "unix",
|
||||
"mode": "simulation",
|
||||
"available": False,
|
||||
"control_available": False,
|
||||
"active_run_id": None,
|
||||
"run_state": None,
|
||||
"provider_ids": [],
|
||||
"isolation": {
|
||||
"network": "unavailable",
|
||||
"process_identity": "missioncore",
|
||||
"artifact_policy": "d-only",
|
||||
},
|
||||
"authority": {
|
||||
"scope": "virtual-only",
|
||||
"actuator_authority": False,
|
||||
"direct_actuator_setpoints_allowed": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _new_run_id(commit: str) -> str:
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dt%H%M%Sz").lower()
|
||||
return f"s1c-{commit[:7]}-{stamp}-{secrets.token_hex(3)}"
|
||||
|
||||
Reference in New Issue
Block a user