feat(mutator): route mutate_decky through emit_decky_mutated with trigger

Mutator now emits one decky_mutated event (RFC 5424 + bus) per
successful mutation instead of the inline decky.<id>.state bus
publish.  The previous state topic published new_services only;
mutation events carry old/new/trigger, which is what the correlation
engine needs to interleave substrate-change markers into attacker
traversals.

- mutate_decky gains trigger: MutationTrigger = "operator" and
  captures old_services before the shuffle; replaces the inline
  _publish_safely(decky.<id>.state) with emit_decky_mutated(...).
- mutate_all derives trigger internally: operator when force or
  only-filter is set (CLI --all, API mutate-now, UI bus request);
  scheduled on interval ticks.  Passed through to each mutate_decky
  call.
- Tests updated: the old decky.<id>.state assertion is replaced
  with decky.<id>.mutation topic + mutation payload shape; 3 new
  tests cover trigger derivation for scheduled / force / only paths.
  26 tests in test_mutator.py green; 116 across mutator + topology
  + bus.
This commit is contained in:
2026-04-21 19:31:31 -04:00
parent f875350d75
commit fa0cdb3ab5
2 changed files with 75 additions and 16 deletions

View File

@@ -199,32 +199,84 @@ class TestMutateAll:
class TestMutateDeckyBusPublish:
@pytest.mark.asyncio
async def test_publishes_decky_state_on_success(self, mock_repo):
async def test_publishes_decky_mutation_on_success(self, mock_repo, tmp_path):
cfg = _make_config()
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
bus = AsyncMock()
log_path = tmp_path / "decnet.log"
with patch("decnet.mutator.engine.write_compose"), \
patch("anyio.to_thread.run_sync", new_callable=AsyncMock):
patch("anyio.to_thread.run_sync", new_callable=AsyncMock), \
patch("decnet.mutator.events.DECNET_INGEST_LOG_FILE", str(log_path)):
ok = await mutate_decky("decky-01", repo=mock_repo, bus=bus)
assert ok is True
bus.publish.assert_awaited_once()
topic = bus.publish.await_args.args[0]
payload = bus.publish.await_args.args[1]
assert topic == "decky.decky-01.state"
assert payload["name"] == "decky-01"
assert isinstance(payload["services"], list)
assert topic == "decky.decky-01.mutation"
assert payload["decky"] == "decky-01"
assert payload["old_services"] == ["ssh"]
assert isinstance(payload["new_services"], list)
assert payload["trigger"] == "operator" # direct mutate_decky call
@pytest.mark.asyncio
async def test_no_publish_on_compose_failure(self, mock_repo):
async def test_no_publish_on_compose_failure(self, mock_repo, tmp_path):
cfg = _make_config()
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
bus = AsyncMock()
log_path = tmp_path / "decnet.log"
with patch("decnet.mutator.engine.write_compose"), \
patch("anyio.to_thread.run_sync",
new_callable=AsyncMock, side_effect=RuntimeError("boom")):
new_callable=AsyncMock, side_effect=RuntimeError("boom")), \
patch("decnet.mutator.events.DECNET_INGEST_LOG_FILE", str(log_path)):
ok = await mutate_decky("decky-01", repo=mock_repo, bus=bus)
assert ok is False
bus.publish.assert_not_awaited()
# No syslog line either — mutation didn't land
assert not log_path.exists()
@pytest.mark.asyncio
async def test_scheduled_trigger_on_interval_tick(self, mock_repo, tmp_path):
"""mutate_all on an interval tick stamps trigger=scheduled."""
old_ts = time.time() - 7200 # due
cfg = _make_config(deckies=[_make_decky(mutate_interval=30, last_mutated=old_ts)])
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
bus = AsyncMock()
log_path = tmp_path / "decnet.log"
with patch("decnet.mutator.engine.write_compose"), \
patch("anyio.to_thread.run_sync", new_callable=AsyncMock), \
patch("decnet.mutator.events.DECNET_INGEST_LOG_FILE", str(log_path)):
await mutate_all(repo=mock_repo, bus=bus, force=False)
bus.publish.assert_awaited_once()
assert bus.publish.await_args.args[1]["trigger"] == "scheduled"
@pytest.mark.asyncio
async def test_operator_trigger_on_force(self, mock_repo, tmp_path):
"""mutate_all(force=True) stamps trigger=operator."""
cfg = _make_config()
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
bus = AsyncMock()
log_path = tmp_path / "decnet.log"
with patch("decnet.mutator.engine.write_compose"), \
patch("anyio.to_thread.run_sync", new_callable=AsyncMock), \
patch("decnet.mutator.events.DECNET_INGEST_LOG_FILE", str(log_path)):
await mutate_all(repo=mock_repo, bus=bus, force=True)
bus.publish.assert_awaited_once()
assert bus.publish.await_args.args[1]["trigger"] == "operator"
@pytest.mark.asyncio
async def test_operator_trigger_on_only_filter(self, mock_repo, tmp_path):
"""mutate_all(only={'d1'}) is a targeted operator action."""
now = time.time()
cfg = _make_config(deckies=[_make_decky("d1", mutate_interval=30, last_mutated=now)])
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
bus = AsyncMock()
log_path = tmp_path / "decnet.log"
with patch("decnet.mutator.engine.write_compose"), \
patch("anyio.to_thread.run_sync", new_callable=AsyncMock), \
patch("decnet.mutator.events.DECNET_INGEST_LOG_FILE", str(log_path)):
await mutate_all(repo=mock_repo, bus=bus, only={"d1"})
bus.publish.assert_awaited_once()
assert bus.publish.await_args.args[1]["trigger"] == "operator"
# ---------------------------------------------------------------------------