This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
from cloud.internal_api.models import (
|
||||
AssignmentModel,
|
||||
DeviceEnrollmentResponse,
|
||||
@@ -16,6 +19,12 @@ from host_agent.identity import HostIdentityStore
|
||||
from storage.device_config import DeviceConfigStore
|
||||
|
||||
|
||||
def _free_loopback_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
return probe.getsockname()[1]
|
||||
|
||||
|
||||
def _config() -> HostAgentConfig:
|
||||
return HostAgentConfig(
|
||||
control_plane_url="https://control.example",
|
||||
@@ -88,7 +97,6 @@ def test_create_application_enrolls_host_and_devices_before_managed_startup(
|
||||
def __init__(self) -> None:
|
||||
self.config = HostAgentConfig(
|
||||
control_plane_url="https://control.example",
|
||||
enrollment_token="one-time-token",
|
||||
enrollment_managed=True,
|
||||
)
|
||||
|
||||
@@ -352,3 +360,98 @@ def test_main_task_cancellation_waits_for_active_work_shutdown() -> None:
|
||||
assert events == ["work-finished", "final-heartbeat", "closed"]
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_console_enabled_serves_http_and_shuts_down_cleanly(tmp_path) -> None:
|
||||
async def scenario() -> None:
|
||||
port = _free_loopback_port()
|
||||
config = HostAgentConfig(
|
||||
control_plane_url="https://control.example",
|
||||
host_id="host-a",
|
||||
token="secret",
|
||||
identity_path=tmp_path / "host_identity.json",
|
||||
local_account_path=tmp_path / "host_local_account.json",
|
||||
console_enabled=True,
|
||||
console_bind_host="127.0.0.1",
|
||||
console_port=port,
|
||||
)
|
||||
application = create_application(config=config, manager=DeviceManager())
|
||||
assert application.console_server is not None
|
||||
|
||||
claim_started = asyncio.Event()
|
||||
claim_cancelled = asyncio.Event()
|
||||
events: list[str] = []
|
||||
|
||||
class BlockingClient:
|
||||
async def claim(self):
|
||||
claim_started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
claim_cancelled.set()
|
||||
raise
|
||||
|
||||
async def aclose(self):
|
||||
events.append("closed")
|
||||
|
||||
class RecordingHeartbeat:
|
||||
async def run(self, stop):
|
||||
await stop.wait()
|
||||
|
||||
async def sync_once(self):
|
||||
events.append("final-heartbeat")
|
||||
|
||||
class IdleProcessor:
|
||||
async def process(self, assignment):
|
||||
raise AssertionError("no assignment expected")
|
||||
|
||||
def request_stop(self):
|
||||
events.append("stop-work")
|
||||
|
||||
application.client = BlockingClient() # type: ignore[assignment]
|
||||
application.heartbeat = RecordingHeartbeat() # type: ignore[assignment]
|
||||
application.processor = IdleProcessor() # type: ignore[assignment]
|
||||
|
||||
stop = asyncio.Event()
|
||||
running = asyncio.create_task(application.run_async(stop))
|
||||
await claim_started.wait()
|
||||
|
||||
response: httpx.Response | None = None
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + 5
|
||||
while loop.time() < deadline:
|
||||
try:
|
||||
response = await http_client.get(
|
||||
f"http://127.0.0.1:{port}/login", timeout=0.5
|
||||
)
|
||||
except httpx.TransportError:
|
||||
await asyncio.sleep(0.05)
|
||||
continue
|
||||
break
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
assert "Login" in response.text
|
||||
|
||||
stop.set()
|
||||
await asyncio.wait_for(running, timeout=5)
|
||||
|
||||
assert claim_cancelled.is_set()
|
||||
assert events == ["stop-work", "final-heartbeat", "closed"]
|
||||
assert application.console_server.should_exit is True
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||||
probe.settimeout(0.5)
|
||||
with suppress(ConnectionRefusedError, OSError):
|
||||
probe.connect(("127.0.0.1", port))
|
||||
raise AssertionError("console socket should be closed after shutdown")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_console_disabled_by_default_opens_no_socket(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
application = create_application(config=_config(), manager=DeviceManager())
|
||||
|
||||
assert application.console_server is None
|
||||
asyncio.run(application.client.aclose())
|
||||
|
||||
Reference in New Issue
Block a user