feat(profiler): wire BEHAVE-SHELL extraction onto attacker.session.ended
The profiler worker now consumes attacker.session.ended on the bus
AND walks unprofiled session_recorded log rows on every tick. Both
paths converge on a single handler that:
1. Validates required payload fields (session_id, decky_id, service,
attacker_ip, shard_path).
2. Builds evidence_ref shard:{decky}/{service}/{shard_basename}#{sid}
and skips when has_observations_for_evidence is True (idempotent
re-runs).
3. Resolves attacker_uuid via get_attacker_uuid_by_ip; defers if the
profiler tick hasn't materialised the row yet.
4. Reads the asciinema shard, slices events for the sid, calls
extract_session, persists each Observation via upsert_observation
(per-row; batch transaction filed as follow-up), then publishes
each on the bus best-effort (fire-and-forget per DEBT-029 §6).
Architecture:
* Handler lives in decnet/profiler/behave_shell/_handler.py — pure
function, unit-tested in isolation.
* Worker.py adds _behave_pump (queue feed), _drain_behave_queue
(per-tick drain), _behave_poll_tick (cursor scan over
session_recorded logs), and _payload_from_log_row (Log → bus-shape
payload projection).
* Poll cursor uses a separate state key
(attacker_worker_session_cursor) so the correlation tick's cursor
doesn't conflate.
* has_observations_for_evidence promoted to BaseRepository abstract.
22 new tests across handler / drain / poll layers covering happy
path, all skip paths, isolation against handler exceptions,
idempotency on re-run, and cursor key separation. TTP worker bus
tests still green — payload field is purely additive.
Closes BEHAVE-INTEGRATION.md Phase 4.
This commit is contained in:
84
tests/profiler/behave_shell/test_worker_behave_drain.py
Normal file
84
tests/profiler/behave_shell/test_worker_behave_drain.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""W.3 bus-path drain tests.
|
||||
|
||||
Exercises ``_drain_behave_queue`` directly without the asyncio worker
|
||||
loop. The handler is unit-tested in
|
||||
``test_handler_session_ended.py``; this file pins the queue-drain
|
||||
plumbing (Event unwrapping, isolation against handler exceptions,
|
||||
empty-queue no-op).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from decnet.profiler.worker import _drain_behave_queue
|
||||
|
||||
|
||||
async def _make_event(payload: dict[str, Any]):
|
||||
"""Build a minimal Event-like object the drain expects."""
|
||||
ev = MagicMock()
|
||||
ev.topic = "attacker.session.ended"
|
||||
ev.payload = payload
|
||||
return ev
|
||||
|
||||
|
||||
async def test_drain_empty_queue_is_noop() -> None:
|
||||
repo = AsyncMock()
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
await _drain_behave_queue(repo, queue, None)
|
||||
repo.has_observations_for_evidence.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_drain_skips_none_sentinel() -> None:
|
||||
repo = AsyncMock()
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
await queue.put(None)
|
||||
await _drain_behave_queue(repo, queue, None)
|
||||
repo.has_observations_for_evidence.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_drain_passes_event_payload_to_handler(monkeypatch) -> None:
|
||||
"""The drain unwraps Event.payload and feeds it to the handler."""
|
||||
captured: list[dict[str, Any]] = []
|
||||
|
||||
async def _fake_handler(repo, payload, publish):
|
||||
captured.append(payload)
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
"decnet.profiler.worker.handle_session_ended", _fake_handler,
|
||||
)
|
||||
repo = AsyncMock()
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
ev = await _make_event({"session_id": "abc", "decky_id": "d"})
|
||||
await queue.put((ev.topic, ev))
|
||||
await _drain_behave_queue(repo, queue, None)
|
||||
assert captured == [{"session_id": "abc", "decky_id": "d"}]
|
||||
|
||||
|
||||
async def test_drain_isolates_handler_exception(monkeypatch) -> None:
|
||||
"""A handler that raises must not crash subsequent events."""
|
||||
call_count = 0
|
||||
|
||||
async def _maybe_blowing_handler(repo, payload, publish):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise RuntimeError("handler exploded")
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
"decnet.profiler.worker.handle_session_ended",
|
||||
_maybe_blowing_handler,
|
||||
)
|
||||
repo = AsyncMock()
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
ev1 = await _make_event({"session_id": "a"})
|
||||
ev2 = await _make_event({"session_id": "b"})
|
||||
await queue.put((ev1.topic, ev1))
|
||||
await queue.put((ev2.topic, ev2))
|
||||
|
||||
# Should not raise; both events should be drained.
|
||||
await _drain_behave_queue(repo, queue, None)
|
||||
assert call_count == 2
|
||||
Reference in New Issue
Block a user