feat(plugins): add runtime handshake boundary
This commit is contained in:
+200
-14
@@ -3,11 +3,19 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from missioncore_plugin_sdk.v0alpha2 import RuntimeActionInvocation
|
||||
from missioncore_plugin_sdk.v0alpha2 import (
|
||||
RuntimeActionInvocation,
|
||||
RuntimeActionResult,
|
||||
RuntimeHandshakeRequest,
|
||||
RuntimeHandshakeResult,
|
||||
RuntimePluginDescriptor,
|
||||
)
|
||||
from pydantic import ValidationError
|
||||
|
||||
import k1link.web.device_plugin_composition as plugin_composition
|
||||
@@ -19,6 +27,7 @@ from k1link.device_plugins.xgrids_k1.facade import (
|
||||
ACTION_STREAM_STOP,
|
||||
ACTION_VIEWER_SETTINGS_UPDATE,
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
XGRIDS_K1_PLUGIN_VERSION,
|
||||
CompatibilityAttestationRequest,
|
||||
ConnectRequest,
|
||||
ViewerSettingsRequest,
|
||||
@@ -33,8 +42,12 @@ from k1link.web.plugin_catalog import DevicePluginCatalog
|
||||
from k1link.web.plugin_runtime import (
|
||||
DevicePluginDispatcher,
|
||||
DevicePluginRuntimeContribution,
|
||||
InProcessDevicePluginRuntime,
|
||||
PluginActionNotFoundError,
|
||||
PluginExecutionError,
|
||||
PluginNotFoundError,
|
||||
PluginRuntimeCompatibilityError,
|
||||
PluginRuntimeUnavailableError,
|
||||
)
|
||||
|
||||
|
||||
@@ -76,6 +89,38 @@ class FakeXgridsService:
|
||||
return {"phase": "idle", "viewer_settings": request.model_dump()}
|
||||
|
||||
|
||||
def _in_process_runtime(
|
||||
adapter: Any,
|
||||
*,
|
||||
plugin_version: str = XGRIDS_K1_PLUGIN_VERSION,
|
||||
host_api_version: str = "missioncore.nodedc/v1alpha2",
|
||||
close: Any | None = None,
|
||||
activate: bool = True,
|
||||
) -> InProcessDevicePluginRuntime:
|
||||
runtime = InProcessDevicePluginRuntime(
|
||||
adapter,
|
||||
RuntimePluginDescriptor(
|
||||
plugin_id=adapter.plugin_id,
|
||||
plugin_version=plugin_version,
|
||||
supported_host_api_versions=(host_api_version,),
|
||||
action_ids=tuple(sorted(adapter.action_ids)),
|
||||
),
|
||||
**({"close": close} if close is not None else {}),
|
||||
)
|
||||
if activate:
|
||||
runtime.handshake(
|
||||
RuntimeHandshakeRequest(
|
||||
handshake_id=uuid4().hex,
|
||||
plugin_id=adapter.plugin_id,
|
||||
plugin_version=plugin_version,
|
||||
host_api_version=host_api_version,
|
||||
requested_at=datetime.now(UTC),
|
||||
required_action_ids=tuple(sorted(adapter.action_ids)),
|
||||
)
|
||||
)
|
||||
return runtime
|
||||
|
||||
|
||||
def test_manifest_and_runtime_facade_declare_identical_actions() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
manifest = next(
|
||||
@@ -112,8 +157,11 @@ def test_composition_closes_a_runtime_that_does_not_match_its_manifest(
|
||||
)
|
||||
closed: list[bool] = []
|
||||
contribution = DevicePluginRuntimeContribution(
|
||||
adapter=XgridsK1PluginFacade(FakeXgridsService()),
|
||||
close=lambda: closed.append(True),
|
||||
runtime=_in_process_runtime(
|
||||
XgridsK1PluginFacade(FakeXgridsService()),
|
||||
close=lambda: closed.append(True),
|
||||
activate=False,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugin_composition,
|
||||
@@ -127,6 +175,47 @@ def test_composition_closes_a_runtime_that_does_not_match_its_manifest(
|
||||
assert closed == [True]
|
||||
|
||||
|
||||
def test_composition_rejects_an_uncorrelated_runtime_handshake(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class UncorrelatedHandshakeRuntime(InProcessDevicePluginRuntime):
|
||||
def handshake(
|
||||
self,
|
||||
request: RuntimeHandshakeRequest,
|
||||
) -> RuntimeHandshakeResult:
|
||||
result = super().handshake(request)
|
||||
return result.model_copy(update={"handshake_id": "another-handshake"})
|
||||
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
manifest = next(
|
||||
candidate
|
||||
for candidate in DevicePluginCatalog(repository_root).manifests()
|
||||
if candidate.metadata.id == XGRIDS_K1_PLUGIN_ID
|
||||
)
|
||||
closed: list[bool] = []
|
||||
adapter = XgridsK1PluginFacade(FakeXgridsService())
|
||||
runtime = UncorrelatedHandshakeRuntime(
|
||||
adapter,
|
||||
RuntimePluginDescriptor(
|
||||
plugin_id=adapter.plugin_id,
|
||||
plugin_version=XGRIDS_K1_PLUGIN_VERSION,
|
||||
supported_host_api_versions=(manifest.apiVersion,),
|
||||
action_ids=tuple(sorted(adapter.action_ids)),
|
||||
),
|
||||
close=lambda: closed.append(True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugin_composition,
|
||||
"_load_factory",
|
||||
lambda _: lambda __: DevicePluginRuntimeContribution(runtime=runtime),
|
||||
)
|
||||
|
||||
with pytest.raises(DevicePluginCompositionError, match="handshake is inconsistent"):
|
||||
plugin_composition._load_contribution(repository_root, manifest)
|
||||
|
||||
assert closed == [True]
|
||||
|
||||
|
||||
def test_composition_loads_and_dispatches_two_synthetic_plugins(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
@@ -149,8 +238,13 @@ def test_composition_loads_and_dispatches_two_synthetic_plugins(
|
||||
closed: list[str] = []
|
||||
contributions = {
|
||||
f"synthetic:{suffix}": DevicePluginRuntimeContribution(
|
||||
adapter=SyntheticAdapter(f"example.{suffix}"),
|
||||
close=lambda suffix=suffix: closed.append(suffix),
|
||||
runtime=_in_process_runtime(
|
||||
SyntheticAdapter(f"example.{suffix}"),
|
||||
plugin_version="0.1.0",
|
||||
host_api_version="missioncore.nodedc/v1alpha1",
|
||||
close=lambda suffix=suffix: closed.append(suffix),
|
||||
activate=False,
|
||||
),
|
||||
)
|
||||
for suffix in ("first", "second")
|
||||
}
|
||||
@@ -205,6 +299,7 @@ def test_composition_loads_and_dispatches_two_synthetic_plugins(
|
||||
environment.dispatcher.invoke(f"example.{suffix}", "state.read", {})
|
||||
)
|
||||
assert state == {"plugin_id": f"example.{suffix}"}
|
||||
assert {item["status"] for item in environment.runtime_health} == {"ready"}
|
||||
finally:
|
||||
environment.close()
|
||||
|
||||
@@ -226,12 +321,16 @@ def test_environment_shutdown_attempts_every_plugin_after_close_failure(
|
||||
legacy_routers=(),
|
||||
_contributions=(
|
||||
DevicePluginRuntimeContribution(
|
||||
adapter=XgridsK1PluginFacade(FakeXgridsService()),
|
||||
close=lambda: closed.append("healthy"),
|
||||
runtime=_in_process_runtime(
|
||||
XgridsK1PluginFacade(FakeXgridsService()),
|
||||
close=lambda: closed.append("healthy"),
|
||||
),
|
||||
),
|
||||
DevicePluginRuntimeContribution(
|
||||
adapter=XgridsK1PluginFacade(FakeXgridsService()),
|
||||
close=failing_close,
|
||||
runtime=_in_process_runtime(
|
||||
XgridsK1PluginFacade(FakeXgridsService()),
|
||||
close=failing_close,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -245,7 +344,9 @@ def test_environment_shutdown_attempts_every_plugin_after_close_failure(
|
||||
|
||||
def test_dispatcher_routes_allowlisted_action_to_xgrids_facade() -> None:
|
||||
service = FakeXgridsService()
|
||||
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(service)])
|
||||
dispatcher = DevicePluginDispatcher(
|
||||
[_in_process_runtime(XgridsK1PluginFacade(service))]
|
||||
)
|
||||
|
||||
state = asyncio.run(
|
||||
dispatcher.invoke(
|
||||
@@ -260,7 +361,9 @@ def test_dispatcher_routes_allowlisted_action_to_xgrids_facade() -> None:
|
||||
|
||||
|
||||
def test_dispatcher_rejects_unknown_plugin_and_action() -> None:
|
||||
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(FakeXgridsService())])
|
||||
dispatcher = DevicePluginDispatcher(
|
||||
[_in_process_runtime(XgridsK1PluginFacade(FakeXgridsService()))]
|
||||
)
|
||||
|
||||
with pytest.raises(PluginNotFoundError):
|
||||
asyncio.run(dispatcher.invoke("missing.plugin", ACTION_STREAM_STOP, {}))
|
||||
@@ -270,7 +373,9 @@ def test_dispatcher_rejects_unknown_plugin_and_action() -> None:
|
||||
|
||||
def test_facade_validates_payload_before_calling_service() -> None:
|
||||
service = FakeXgridsService()
|
||||
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(service)])
|
||||
dispatcher = DevicePluginDispatcher(
|
||||
[_in_process_runtime(XgridsK1PluginFacade(service))]
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
asyncio.run(
|
||||
@@ -332,7 +437,9 @@ def test_facade_preserves_existing_runtime_operations(
|
||||
expected_call: str,
|
||||
) -> None:
|
||||
service = FakeXgridsService()
|
||||
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(service)])
|
||||
dispatcher = DevicePluginDispatcher(
|
||||
[_in_process_runtime(XgridsK1PluginFacade(service))]
|
||||
)
|
||||
|
||||
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, action_id, payload))
|
||||
|
||||
@@ -348,10 +455,89 @@ def test_sync_runtime_actions_run_outside_the_api_event_loop() -> None:
|
||||
return super().stop()
|
||||
|
||||
service = ThreadAwareService()
|
||||
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(service)])
|
||||
dispatcher = DevicePluginDispatcher(
|
||||
[_in_process_runtime(XgridsK1PluginFacade(service))]
|
||||
)
|
||||
event_loop_thread = threading.get_ident()
|
||||
|
||||
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, ACTION_STREAM_STOP, {}))
|
||||
|
||||
assert service.thread_id is not None
|
||||
assert service.thread_id != event_loop_thread
|
||||
|
||||
|
||||
def test_runtime_requires_successful_handshake_before_dispatch() -> None:
|
||||
adapter = XgridsK1PluginFacade(FakeXgridsService())
|
||||
runtime = _in_process_runtime(adapter, activate=False)
|
||||
dispatcher = DevicePluginDispatcher([runtime])
|
||||
|
||||
assert runtime.health().status == "starting"
|
||||
with pytest.raises(PluginRuntimeUnavailableError, match="not ready"):
|
||||
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, ACTION_STREAM_STOP, {}))
|
||||
|
||||
with pytest.raises(PluginRuntimeCompatibilityError, match="host API"):
|
||||
runtime.handshake(
|
||||
RuntimeHandshakeRequest(
|
||||
handshake_id="handshake-incompatible",
|
||||
plugin_id=XGRIDS_K1_PLUGIN_ID,
|
||||
plugin_version=XGRIDS_K1_PLUGIN_VERSION,
|
||||
host_api_version="missioncore.nodedc/v1alpha1",
|
||||
requested_at=datetime.now(UTC),
|
||||
required_action_ids=tuple(sorted(adapter.action_ids)),
|
||||
)
|
||||
)
|
||||
|
||||
assert runtime.health().status == "starting"
|
||||
|
||||
|
||||
def test_runtime_health_transitions_to_stopped_and_close_is_idempotent() -> None:
|
||||
closed: list[bool] = []
|
||||
runtime = _in_process_runtime(
|
||||
XgridsK1PluginFacade(FakeXgridsService()),
|
||||
close=lambda: closed.append(True),
|
||||
)
|
||||
|
||||
assert runtime.health().status == "ready"
|
||||
runtime.close()
|
||||
runtime.close()
|
||||
|
||||
assert runtime.health().status == "stopped"
|
||||
assert closed == [True]
|
||||
|
||||
|
||||
def test_dispatcher_rejects_uncorrelated_transport_result() -> None:
|
||||
class UncorrelatedRuntime(InProcessDevicePluginRuntime):
|
||||
async def invoke(
|
||||
self,
|
||||
invocation: RuntimeActionInvocation,
|
||||
) -> RuntimeActionResult:
|
||||
result = await super().invoke(invocation)
|
||||
return result.model_copy(update={"invocation_id": "another-invocation"})
|
||||
|
||||
adapter = XgridsK1PluginFacade(FakeXgridsService())
|
||||
descriptor = RuntimePluginDescriptor(
|
||||
plugin_id=adapter.plugin_id,
|
||||
plugin_version=XGRIDS_K1_PLUGIN_VERSION,
|
||||
supported_host_api_versions=("missioncore.nodedc/v1alpha2",),
|
||||
action_ids=tuple(sorted(adapter.action_ids)),
|
||||
)
|
||||
runtime = UncorrelatedRuntime(adapter, descriptor)
|
||||
runtime.handshake(
|
||||
RuntimeHandshakeRequest(
|
||||
handshake_id="handshake-correlation",
|
||||
plugin_id=adapter.plugin_id,
|
||||
plugin_version=XGRIDS_K1_PLUGIN_VERSION,
|
||||
host_api_version="missioncore.nodedc/v1alpha2",
|
||||
requested_at=datetime.now(UTC),
|
||||
required_action_ids=descriptor.action_ids,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(PluginExecutionError, match="uncorrelated"):
|
||||
asyncio.run(
|
||||
DevicePluginDispatcher([runtime]).invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_STREAM_STOP,
|
||||
{},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -339,6 +339,52 @@ def test_plugin_sdk_v0alpha2_compatibility_blocks_unproven_control() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_plugin_sdk_v0alpha2_runtime_handshake_is_versioned_and_closed() -> None:
|
||||
descriptor = sdk.RuntimePluginDescriptor(
|
||||
plugin_id="nodedc.device.synthetic",
|
||||
plugin_version="0.1.0",
|
||||
supported_host_api_versions=("missioncore.nodedc/v1alpha2",),
|
||||
action_ids=("state.read", "acquisition.start"),
|
||||
)
|
||||
request = sdk.RuntimeHandshakeRequest(
|
||||
handshake_id="handshake-001",
|
||||
plugin_id=descriptor.plugin_id,
|
||||
plugin_version=descriptor.plugin_version,
|
||||
host_api_version="missioncore.nodedc/v1alpha2",
|
||||
requested_at=NOW,
|
||||
required_action_ids=descriptor.action_ids,
|
||||
)
|
||||
result = sdk.RuntimeHandshakeResult(
|
||||
handshake_id=request.handshake_id,
|
||||
runtime_instance_id="runtime.synthetic-001",
|
||||
accepted_host_api_version=request.host_api_version,
|
||||
descriptor=descriptor,
|
||||
ready_at=NOW,
|
||||
)
|
||||
health = sdk.RuntimeHealthSnapshot(
|
||||
runtime_instance_id=result.runtime_instance_id,
|
||||
plugin_id=descriptor.plugin_id,
|
||||
plugin_version=descriptor.plugin_version,
|
||||
status="ready",
|
||||
observed_at=NOW,
|
||||
)
|
||||
|
||||
assert descriptor.runtime_protocol_version == sdk.RUNTIME_PROTOCOL_VERSION
|
||||
assert result.descriptor == descriptor
|
||||
assert health.status == "ready"
|
||||
with pytest.raises(ValidationError, match="runtime action ids must be unique"):
|
||||
sdk.RuntimePluginDescriptor.model_validate(
|
||||
{
|
||||
**descriptor.model_dump(),
|
||||
"action_ids": ["state.read", "state.read"],
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValidationError, match="extra"):
|
||||
sdk.RuntimeHandshakeRequest.model_validate(
|
||||
{**request.model_dump(), "process_id": 42}
|
||||
)
|
||||
|
||||
|
||||
def test_plugin_sdk_v0alpha2_exports_closed_json_schemas_and_vocabulary() -> None:
|
||||
schemas = sdk.contract_json_schemas()
|
||||
|
||||
@@ -348,6 +394,10 @@ def test_plugin_sdk_v0alpha2_exports_closed_json_schemas_and_vocabulary() -> Non
|
||||
"OperationEvent",
|
||||
"RuntimeActionInvocation",
|
||||
"RuntimeActionResult",
|
||||
"RuntimePluginDescriptor",
|
||||
"RuntimeHandshakeRequest",
|
||||
"RuntimeHandshakeResult",
|
||||
"RuntimeHealthSnapshot",
|
||||
"CanonicalStreamEnvelope",
|
||||
"EvidenceRecord",
|
||||
"CompatibilityAssessment",
|
||||
|
||||
Reference in New Issue
Block a user