feat(host-agent): synchronize device snapshots
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from cloud.internal_api.models import DeviceSnapshotModel, HeartbeatResponse
|
||||
from device.manager import DeviceManager
|
||||
from host_agent.client import HostAgentClient
|
||||
from host_agent.config import HostAgentConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
|
||||
def build_device_snapshot(manager: DeviceManager) -> list[DeviceSnapshotModel]:
|
||||
return [
|
||||
DeviceSnapshotModel(
|
||||
device_id=device.id,
|
||||
driver_type=device.driver_type,
|
||||
status=device.status,
|
||||
capability_tags=list(device.capability_tags),
|
||||
)
|
||||
for device in sorted(manager.list_devices(), key=lambda item: item.id)
|
||||
]
|
||||
|
||||
|
||||
class HeartbeatSynchronizer:
|
||||
def __init__(
|
||||
self,
|
||||
manager: DeviceManager,
|
||||
client: HostAgentClient,
|
||||
config: HostAgentConfig,
|
||||
*,
|
||||
address: str | None = None,
|
||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
||||
) -> None:
|
||||
self.manager = manager
|
||||
self.client = client
|
||||
self.config = config
|
||||
self.address = address
|
||||
self._sleep = sleep
|
||||
|
||||
async def sync_once(self) -> HeartbeatResponse:
|
||||
return await self.client.heartbeat(
|
||||
build_device_snapshot(self.manager),
|
||||
address=self.address,
|
||||
)
|
||||
|
||||
async def run(self, stop: asyncio.Event) -> None:
|
||||
while not stop.is_set():
|
||||
await self.sync_once()
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
stop.wait(),
|
||||
timeout=self.config.heartbeat_interval_seconds,
|
||||
)
|
||||
except TimeoutError:
|
||||
continue
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from cloud.internal_api.models import HeartbeatResponse
|
||||
from device.manager import DeviceManager
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.heartbeat import HeartbeatSynchronizer, build_device_snapshot
|
||||
|
||||
|
||||
def _config() -> HostAgentConfig:
|
||||
return HostAgentConfig(
|
||||
control_plane_url="https://control.example",
|
||||
host_id="host-a",
|
||||
token="secret",
|
||||
heartbeat_interval_seconds=0.01,
|
||||
)
|
||||
|
||||
|
||||
def test_build_device_snapshot_copies_complete_non_secret_state() -> None:
|
||||
manager = DeviceManager()
|
||||
manager.register_device(
|
||||
"device-b",
|
||||
lambda: object(), # type: ignore[arg-type,return-value]
|
||||
driver_type="appium",
|
||||
connection_info={"password": "must-not-leave-host"},
|
||||
status="busy",
|
||||
capability_tags=["android", "physical"],
|
||||
)
|
||||
manager.register_device(
|
||||
"device-a",
|
||||
lambda: object(), # type: ignore[arg-type,return-value]
|
||||
status="offline",
|
||||
)
|
||||
|
||||
snapshot = build_device_snapshot(manager)
|
||||
|
||||
assert [device.device_id for device in snapshot] == ["device-a", "device-b"]
|
||||
assert snapshot[1].driver_type == "appium"
|
||||
assert snapshot[1].status == "busy"
|
||||
assert snapshot[1].capability_tags == ["android", "physical"]
|
||||
assert "must-not-leave-host" not in repr(snapshot)
|
||||
|
||||
|
||||
def test_heartbeat_synchronizer_runs_at_configured_interval_until_stopped() -> None:
|
||||
manager = DeviceManager()
|
||||
manager.register_device(
|
||||
"device-a",
|
||||
lambda: object(), # type: ignore[arg-type,return-value]
|
||||
)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
class FakeClient:
|
||||
async def heartbeat(self, devices, *, address=None):
|
||||
calls.append([device.device_id for device in devices])
|
||||
return HeartbeatResponse(
|
||||
host_id="host-a",
|
||||
accepted_devices=len(devices),
|
||||
received_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
stop = asyncio.Event()
|
||||
synchronizer = HeartbeatSynchronizer(
|
||||
manager,
|
||||
FakeClient(), # type: ignore[arg-type]
|
||||
_config(),
|
||||
)
|
||||
task = asyncio.create_task(synchronizer.run(stop))
|
||||
while len(calls) < 3:
|
||||
await asyncio.sleep(0.005)
|
||||
stop.set()
|
||||
await task
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert calls == [["device-a"], ["device-a"], ["device-a"]]
|
||||
Reference in New Issue
Block a user