feat(simulation): add Polygon live worker gateway
This commit is contained in:
+134
-2
@@ -18,7 +18,7 @@ from k1link.simulation import (
|
||||
RunKind,
|
||||
RunState,
|
||||
)
|
||||
from k1link.web.polygon_api import build_polygon_router
|
||||
from k1link.web.polygon_api import StartStockRoverRequest, build_polygon_router
|
||||
|
||||
SHA_A = "a" * 64
|
||||
SHA_B = "b" * 64
|
||||
@@ -130,7 +130,8 @@ def test_polygon_api_is_read_only_and_fails_closed_when_unconfigured() -> None:
|
||||
router = build_polygon_router(root_provider=lambda: None)
|
||||
routes = [route for route in router.routes if isinstance(route, APIRoute)]
|
||||
|
||||
assert all(route.methods <= {"GET", "HEAD"} for route in routes)
|
||||
archive_routes = [route for route in routes if "/worker" not in route.path]
|
||||
assert all(route.methods <= {"GET", "HEAD"} for route in archive_routes)
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
_endpoint(router, "/api/v1/polygon/runs", "GET")(limit=20)
|
||||
assert failure.value.status_code == 503
|
||||
@@ -183,3 +184,134 @@ def test_polygon_api_rejects_corrupt_evidence_without_partial_response(
|
||||
_endpoint(router, "/api/v1/polygon/runs", "GET")(limit=20)
|
||||
assert failure.value.status_code == 500
|
||||
assert "целостности" in failure.value.detail
|
||||
|
||||
|
||||
class _FakeWorkerGateway:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
self.active_run_id: str | None = None
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
return self._status()
|
||||
|
||||
def live(self) -> dict[str, Any]:
|
||||
if self.active_run_id is None:
|
||||
raise AssertionError("test worker has no active run")
|
||||
return {
|
||||
"schema_version": "missioncore.vehicle-state/v1",
|
||||
"run_id": self.active_run_id,
|
||||
"sequence": 1,
|
||||
"observed_at_utc": "2026-07-24T18:00:00Z",
|
||||
"host_monotonic_ns": 123,
|
||||
"sim_time_ns": 456,
|
||||
"frame_id": "map_enu",
|
||||
"child_frame_id": "base_link_flu",
|
||||
"pose": {
|
||||
"position_m": {"x": 1.0, "y": 2.0, "z": 0.1},
|
||||
"orientation_xyzw": {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0},
|
||||
},
|
||||
"source": {
|
||||
"provider": "gazebo",
|
||||
"topic": "/world/rover/dynamic_pose/info",
|
||||
"signal": "ground-truth",
|
||||
"quality": "diagnostic",
|
||||
},
|
||||
"safety": {
|
||||
"scope": "virtual-only",
|
||||
"actuator_authority": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
|
||||
def start(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
mission_core_commit: str,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append(("start", idempotency_key))
|
||||
assert mission_core_commit == "d" * 40
|
||||
self.active_run_id = run_id
|
||||
return self._status()
|
||||
|
||||
def stop(self, *, run_id: str, idempotency_key: str) -> dict[str, Any]:
|
||||
self.calls.append(("stop", idempotency_key))
|
||||
assert run_id == self.active_run_id
|
||||
self.active_run_id = None
|
||||
return self._status()
|
||||
|
||||
def _status(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "missioncore.simulation-worker-status/v1",
|
||||
"worker_id": "mission-gpu-s1",
|
||||
"transport": "unix",
|
||||
"mode": "simulation",
|
||||
"available": True,
|
||||
"control_available": True,
|
||||
"active_run_id": self.active_run_id,
|
||||
"run_state": "running" if self.active_run_id else None,
|
||||
"provider_ids": ["px4-gazebo-stock-rover"] if self.active_run_id else [],
|
||||
"isolation": {
|
||||
"network": "loopback-only-netns",
|
||||
"process_identity": "missioncore",
|
||||
"artifact_policy": "d-only",
|
||||
},
|
||||
"authority": {
|
||||
"scope": "virtual-only",
|
||||
"actuator_authority": False,
|
||||
"direct_actuator_setpoints_allowed": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_polygon_worker_api_fails_closed_then_proxies_virtual_only_lifecycle() -> None:
|
||||
worker = _FakeWorkerGateway()
|
||||
disabled = build_polygon_router(
|
||||
root_provider=lambda: None,
|
||||
worker_provider=lambda: worker,
|
||||
control_provider=lambda: False,
|
||||
commit_provider=lambda: "d" * 40,
|
||||
)
|
||||
status = _endpoint(disabled, "/api/v1/polygon/worker", "GET")()
|
||||
assert status["available"] is True
|
||||
assert status["control_available"] is False
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
_endpoint(disabled, "/api/v1/polygon/worker/runs", "POST")(
|
||||
request=StartStockRoverRequest(scenario_id="stock-rover-ackermann"),
|
||||
idempotency_key="start-disabled",
|
||||
)
|
||||
assert failure.value.status_code == 403
|
||||
|
||||
enabled = build_polygon_router(
|
||||
root_provider=lambda: None,
|
||||
worker_provider=lambda: worker,
|
||||
control_provider=lambda: True,
|
||||
commit_provider=lambda: "d" * 40,
|
||||
)
|
||||
running = _endpoint(enabled, "/api/v1/polygon/worker/runs", "POST")(
|
||||
request=StartStockRoverRequest(scenario_id="stock-rover-ackermann"),
|
||||
idempotency_key="start-001",
|
||||
)
|
||||
assert running["active_run_id"].startswith("s1c-ddddddd-")
|
||||
live = _endpoint(enabled, "/api/v1/polygon/worker/live", "GET")()
|
||||
assert live["run_id"] == running["active_run_id"]
|
||||
assert live["source"]["quality"] == "diagnostic"
|
||||
stopped = _endpoint(enabled, "/api/v1/polygon/worker/runs/{run_id}/stop", "POST")(
|
||||
run_id=running["active_run_id"],
|
||||
idempotency_key="stop-001",
|
||||
)
|
||||
assert stopped["active_run_id"] is None
|
||||
assert worker.calls == [("start", "start-001"), ("stop", "stop-001")]
|
||||
|
||||
|
||||
def test_polygon_worker_status_is_explicitly_unavailable_when_not_registered() -> None:
|
||||
router = build_polygon_router(
|
||||
root_provider=lambda: None,
|
||||
worker_provider=lambda: None,
|
||||
)
|
||||
status = _endpoint(router, "/api/v1/polygon/worker", "GET")()
|
||||
assert status["schema_version"] == "missioncore.simulation-worker-status/v1"
|
||||
assert status["available"] is False
|
||||
assert status["control_available"] is False
|
||||
assert status["authority"]["scope"] == "virtual-only"
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.simulation.worker_gateway import (
|
||||
REQUEST_SCHEMA,
|
||||
RESPONSE_SCHEMA,
|
||||
SimulationWorkerGatewayError,
|
||||
SimulationWorkerUnavailableError,
|
||||
UnixSocketWorkerGateway,
|
||||
)
|
||||
|
||||
|
||||
def _status() -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "missioncore.simulation-worker-status/v1",
|
||||
"worker_id": "mission-gpu-s1",
|
||||
"transport": "unix",
|
||||
"mode": "simulation",
|
||||
"available": True,
|
||||
"control_available": True,
|
||||
"active_run_id": None,
|
||||
"run_state": None,
|
||||
"provider_ids": [],
|
||||
"isolation": {
|
||||
"network": "loopback-only-netns",
|
||||
"process_identity": "missioncore",
|
||||
"artifact_policy": "d-only",
|
||||
},
|
||||
"authority": {
|
||||
"scope": "virtual-only",
|
||||
"actuator_authority": False,
|
||||
"direct_actuator_setpoints_allowed": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _serve_once(
|
||||
socket_path: Path,
|
||||
result: dict[str, Any],
|
||||
*,
|
||||
mutate_response: bool = False,
|
||||
) -> tuple[threading.Thread, list[dict[str, Any]]]:
|
||||
requests: list[dict[str, Any]] = []
|
||||
ready = threading.Event()
|
||||
|
||||
def serve() -> None:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
|
||||
server.bind(str(socket_path))
|
||||
server.listen(1)
|
||||
ready.set()
|
||||
connection, _ = server.accept()
|
||||
with connection:
|
||||
request = json.loads(connection.makefile("rb").readline())
|
||||
requests.append(request)
|
||||
response = {
|
||||
"schema_version": RESPONSE_SCHEMA,
|
||||
"request_id": "wrong" if mutate_response else request["request_id"],
|
||||
"ok": True,
|
||||
"result": result,
|
||||
"error": None,
|
||||
}
|
||||
connection.sendall(
|
||||
json.dumps(response, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
)
|
||||
|
||||
thread = threading.Thread(target=serve, daemon=True)
|
||||
thread.start()
|
||||
assert ready.wait(timeout=2)
|
||||
return thread, requests
|
||||
|
||||
|
||||
def test_unix_worker_gateway_uses_exact_bounded_private_protocol() -> None:
|
||||
socket_path = Path(f"/tmp/mc-{uuid4().hex}.sock")
|
||||
thread, requests = _serve_once(socket_path, _status())
|
||||
gateway = UnixSocketWorkerGateway(socket_path)
|
||||
|
||||
try:
|
||||
status = gateway.status()
|
||||
thread.join(timeout=2)
|
||||
|
||||
assert status["worker_id"] == "mission-gpu-s1"
|
||||
assert requests == [
|
||||
{
|
||||
"schema_version": REQUEST_SCHEMA,
|
||||
"request_id": requests[0]["request_id"],
|
||||
"operation": "status",
|
||||
"payload": {},
|
||||
}
|
||||
]
|
||||
assert len(requests[0]["request_id"]) == 32
|
||||
finally:
|
||||
socket_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_unix_worker_gateway_rejects_response_identity_drift() -> None:
|
||||
socket_path = Path(f"/tmp/mc-{uuid4().hex}.sock")
|
||||
thread, _ = _serve_once(socket_path, _status(), mutate_response=True)
|
||||
gateway = UnixSocketWorkerGateway(socket_path)
|
||||
|
||||
try:
|
||||
with pytest.raises(SimulationWorkerGatewayError, match="identity"):
|
||||
gateway.status()
|
||||
thread.join(timeout=2)
|
||||
finally:
|
||||
socket_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_unix_worker_gateway_reports_missing_worker_without_path_disclosure(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
gateway = UnixSocketWorkerGateway(tmp_path / "missing.sock")
|
||||
|
||||
with pytest.raises(SimulationWorkerUnavailableError) as failure:
|
||||
gateway.status()
|
||||
assert str(tmp_path) not in str(failure.value)
|
||||
Reference in New Issue
Block a user