feat(workers): bus-backed Workers panel (registry, control, installed flag)
Ships the backend half of Config → Workers:
* Worker registry aggregates `system.*.health` + `system.bus.health`
heartbeats into a last-seen dict; OK / STALE / UNKNOWN tiers drop
out of a 90s window (3× the 30s heartbeat interval).
* `GET /api/v1/workers` returns the snapshot plus `bus_connected`
(so the UI can explain "all UNKNOWN" when the bus socket is down)
and a per-row `installed` flag populated from
`systemctl list-unit-files decnet-*.service` (cached 30s).
* `POST /api/v1/workers/{name}/stop` publishes a stop intent on
`system.<name>.control`; workers listen via the shared control
listener in `bus/publish.py`.
* Heartbeat + control listener wired into collector / profiler /
sniffer / prober / mutator worker loops. API self-heartbeats too
so the panel always has one ground-truth row.
* Topic helper `system_control(name)` + tests covering builder
validation, control listener shutdown path, and the API surface
(auth gating, bus-connected field, unknown-name 404).
Adds `StartFailure` / `StartAllResponse` models in anticipation of
the upcoming start endpoints (DEBT-034).
This commit is contained in:
77
tests/bus/test_control_listener.py
Normal file
77
tests/bus/test_control_listener.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Tests for :func:`run_control_listener`.
|
||||
|
||||
The listener is the worker-side half of the Workers panel stop flow:
|
||||
consume ``system.<worker>.control`` messages, set a shutdown event on a
|
||||
well-formed ``{"action": "stop"}``, and ignore everything else without
|
||||
raising.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus import topics as _topics
|
||||
from decnet.bus.fake import FakeBus
|
||||
from decnet.bus.publish import run_control_listener
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_control_listener_sets_shutdown_on_stop() -> None:
|
||||
bus = FakeBus()
|
||||
await bus.connect()
|
||||
shutdown = asyncio.Event()
|
||||
try:
|
||||
task = asyncio.create_task(run_control_listener(bus, "mutator", shutdown))
|
||||
# Give the subscribe() call a tick to register before we publish.
|
||||
await asyncio.sleep(0)
|
||||
await bus.publish(
|
||||
_topics.system_control("mutator"),
|
||||
{"action": _topics.WORKER_CONTROL_STOP, "requested_by": "admin"},
|
||||
event_type="control",
|
||||
)
|
||||
await asyncio.wait_for(task, timeout=1.0)
|
||||
assert shutdown.is_set()
|
||||
finally:
|
||||
await bus.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_control_listener_ignores_malformed() -> None:
|
||||
bus = FakeBus()
|
||||
await bus.connect()
|
||||
shutdown = asyncio.Event()
|
||||
try:
|
||||
task = asyncio.create_task(run_control_listener(bus, "mutator", shutdown))
|
||||
await asyncio.sleep(0)
|
||||
# Unknown action, non-dict-ish field, missing action — none of
|
||||
# these should raise or trigger shutdown.
|
||||
await bus.publish(
|
||||
_topics.system_control("mutator"),
|
||||
{"action": "bogus"}, event_type="control",
|
||||
)
|
||||
await bus.publish(
|
||||
_topics.system_control("mutator"),
|
||||
{"requested_by": "admin"}, event_type="control",
|
||||
)
|
||||
# Now send a real stop to unblock the task so the test terminates.
|
||||
await bus.publish(
|
||||
_topics.system_control("mutator"),
|
||||
{"action": _topics.WORKER_CONTROL_STOP}, event_type="control",
|
||||
)
|
||||
await asyncio.wait_for(task, timeout=1.0)
|
||||
assert shutdown.is_set()
|
||||
finally:
|
||||
await bus.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_control_listener_none_bus_awaits_shutdown() -> None:
|
||||
# With bus=None the listener degrades to awaiting the shutdown event
|
||||
# directly — callers can create_task() unconditionally.
|
||||
shutdown = asyncio.Event()
|
||||
task = asyncio.create_task(run_control_listener(None, "mutator", shutdown))
|
||||
await asyncio.sleep(0)
|
||||
assert not task.done()
|
||||
shutdown.set()
|
||||
await asyncio.wait_for(task, timeout=1.0)
|
||||
@@ -61,3 +61,14 @@ def test_attacker_builder_rejects_empty() -> None:
|
||||
def test_system_health_builder() -> None:
|
||||
assert topics.system_health("sniffer") == "system.sniffer.health"
|
||||
assert topics.system_health("mutator") == "system.mutator.health"
|
||||
|
||||
|
||||
def test_system_control_builder() -> None:
|
||||
assert topics.system_control("mutator") == "system.mutator.control"
|
||||
assert topics.system_control("collector") == "system.collector.control"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "has.dot", "has*wildcard", "has>wild", "with space", "with\ttab"])
|
||||
def test_system_control_rejects_bad_segments(bad: str) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
topics.system_control(bad)
|
||||
|
||||
Reference in New Issue
Block a user