homelab-codex-ws/services/node-agent/tests/test_action_dispatch.py
oskar 4658089e21 fix(kb): przepiecie wszystkich odwolan wewnetrznych po migracji
126 plikow (md, yaml, sh, py) odwolywalo sie do sciezek sprzed migracji.

  15  markdown-linkow [..](..) -> policzona sciezka WZGLEDNA wobec pliku
      odsylajacego (wczesniej czesc z nich byla repo-root-relative i nie
      rozwiazywala sie z katalogu, w ktorym lezala)
 200  odwolan tekstowych (backticki, proza, yaml, importy w kodzie)
      -> nowa sciezka repo-root-relative, zgodnie z konwencja repo
   5  linkow rodzenstwa (gole nazwy plikow, np. "](DEPLOY.md)") — dzialaly
      tylko w starym katalogu; przeliczone recznie

Objete m.in.: CLAUDE.md (scripts/onboard/README.md -> kb/runbooks/
node-onboarding-tool.md, docs/backlog.md -> kb/phases/backlog.md),
README.md, .claude/skills/, 20 session logow, kod jobow.

Ostatnie 5 odwolan pochodzi z tresci wciagnietej rebasem z origin/master
(session log 2026-07-31, override node-agenta na SOLARII, dwie pozycje
backlogu) — wskazywaly na docs/incidents/, docs/kb/modules/ i
services/narty27/README.md sprzed migracji.

Dodany wzajemny link miedzy kb/services/control-plane.md (stub kodu)
a kb/subsystems/control-plane.md (opis, deprecated) — dwa dokumenty o tym
samym systemie, latwe do pomylenia.

Weryfikacja na 790 plikach: 0 odwolan do starych sciezek,
0 martwych linkow markdown. Lint OKF: 190/190 plikow ZGODNE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:58:46 +02:00

251 lines
8.7 KiB
Python

"""Tests for NodeAgent remediation dispatch: pull_dispatched_actions /
process_dispatched_actions / _execute_dispatched_action.
Covers the security gates required by kb/phases/backlog.md "PROJEKT: remediacja
bez SSH": the agent executes only actions addressed to its own node, refuses
anything outside the container_restart whitelist, never restarts its own
container, and treats a repeated dispatch of the same action_id as a no-op.
"""
from __future__ import annotations
import json
import shutil
import time
from unittest.mock import MagicMock
import pytest
import node_agent
@pytest.fixture(autouse=True)
def _isolated_runtime_state():
"""ACTIONS_DIR/STATE_DIR/EVENTS_DIR are a single temp dir shared for the
whole test session (conftest.py sets RUNTIME_PATH once at import time).
Clear the dispatch/processed-actions/events state before and after each
test so tests don't leak into each other or other test modules."""
def _clear():
for d in (node_agent.ACTIONS_DIR, node_agent.EVENTS_DIR, node_agent.STATE_DIR):
if d.exists():
shutil.rmtree(d)
d.mkdir(parents=True, exist_ok=True)
_clear()
yield
_clear()
def _write_dispatch(agent, action_id, node, action_type="container_restart",
container_name="zigbee2mqtt"):
inbox = agent._dispatch_inbox_dir()
inbox.mkdir(parents=True, exist_ok=True)
path = inbox / f"{action_id}.json"
path.write_text(json.dumps({
"action_id": action_id,
"type": action_type,
"node": node,
"service": container_name,
"container_name": container_name,
"dispatched_at": time.time(),
}))
return path
def _fake_docker_client():
client = MagicMock()
container = MagicMock()
client.containers.get.return_value = container
return client, container
def _last_action_result_payload(agent):
events_dir = agent._node_events_dir()
files = sorted(events_dir.glob("evt-*-action_result-*.json"))
assert files, "expected an action_result event to have been emitted"
return json.loads(files[-1].read_text())["payload"]
# ---------------------------------------------------------------------------
# Node scoping
# ---------------------------------------------------------------------------
def test_executes_action_addressed_to_own_node(agent):
agent.docker_client, container = _fake_docker_client()
_write_dispatch(agent, "act-1", node=agent.node_name)
agent.process_dispatched_actions()
container.restart.assert_called_once()
payload = _last_action_result_payload(agent)
assert payload["action_id"] == "act-1"
assert payload["success"] is True
def test_refuses_action_addressed_to_another_node(agent):
agent.docker_client, container = _fake_docker_client()
_write_dispatch(agent, "act-2", node="some-other-node")
# Force it into THIS node's inbox directly (simulates a mis-delivery —
# the dispatch dir is normally already scoped by node name).
inbox = agent._dispatch_inbox_dir()
action_file = inbox / "act-2.json"
data = json.loads(action_file.read_text())
assert data["node"] == "some-other-node"
agent.process_dispatched_actions()
container.restart.assert_not_called()
payload = _last_action_result_payload(agent)
assert payload["success"] is False
assert "not '" + agent.node_name + "'" in payload["error"]
# ---------------------------------------------------------------------------
# Type whitelist
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("bad_type", ["redeploy", "disk_cleanup", "shell_exec", "alert_only"])
def test_rejects_non_whitelisted_action_types(agent, bad_type):
agent.docker_client, container = _fake_docker_client()
_write_dispatch(agent, f"act-{bad_type}", node=agent.node_name, action_type=bad_type)
agent.process_dispatched_actions()
container.restart.assert_not_called()
payload = _last_action_result_payload(agent)
assert payload["success"] is False
assert "not whitelisted" in payload["error"]
def test_accepts_container_restart(agent):
assert "container_restart" in node_agent.ALLOWED_DISPATCH_ACTION_TYPES
assert node_agent.ALLOWED_DISPATCH_ACTION_TYPES == {"container_restart"}
# ---------------------------------------------------------------------------
# Self-restart guard
# ---------------------------------------------------------------------------
def test_refuses_to_restart_itself(agent):
agent.docker_client, container = _fake_docker_client()
_write_dispatch(agent, "act-self", node=agent.node_name, container_name="node-agent")
agent.process_dispatched_actions()
container.restart.assert_not_called()
agent.docker_client.containers.get.assert_not_called()
payload = _last_action_result_payload(agent)
assert payload["success"] is False
assert "restart itself" in payload["error"]
# ---------------------------------------------------------------------------
# Idempotency
# ---------------------------------------------------------------------------
def test_same_action_id_executes_only_once(agent):
agent.docker_client, container = _fake_docker_client()
_write_dispatch(agent, "act-dup", node=agent.node_name)
agent.process_dispatched_actions()
assert container.restart.call_count == 1
# Dispatch file is consumed after processing (real pipeline: rsync
# --remove-source-files means it can't be re-pulled either), but even if
# the SAME action_id is re-delivered, the processed marker must block it.
_write_dispatch(agent, "act-dup", node=agent.node_name)
agent.process_dispatched_actions()
assert container.restart.call_count == 1, "action_id re-delivery must be a no-op"
def test_dispatch_file_removed_after_processing(agent):
agent.docker_client, _ = _fake_docker_client()
action_file = _write_dispatch(agent, "act-cleanup", node=agent.node_name)
agent.process_dispatched_actions()
assert not action_file.exists()
# ---------------------------------------------------------------------------
# Docker restart failure surfaces as a failed action_result
# ---------------------------------------------------------------------------
def test_docker_restart_exception_reports_failure(agent):
client = MagicMock()
client.containers.get.side_effect = Exception("no such container")
agent.docker_client = client
_write_dispatch(agent, "act-fail", node=agent.node_name)
agent.process_dispatched_actions()
payload = _last_action_result_payload(agent)
assert payload["success"] is False
assert "no such container" in payload["error"]
def test_no_docker_client_reports_failure(agent):
agent.docker_client = None
_write_dispatch(agent, "act-nodocker", node=agent.node_name)
agent.process_dispatched_actions()
payload = _last_action_result_payload(agent)
assert payload["success"] is False
assert "Docker SDK unavailable" in payload["error"]
# ---------------------------------------------------------------------------
# pull_dispatched_actions: rsync gating (mirrors test_ship_events_to_vps.py)
# ---------------------------------------------------------------------------
def test_pull_skips_when_vps_host_not_set(agent, monkeypatch):
monkeypatch.setattr(node_agent, "VPS_EVENTS_HOST", "")
fake_run = MagicMock()
monkeypatch.setattr(node_agent.subprocess, "run", fake_run)
agent.pull_dispatched_actions()
fake_run.assert_not_called()
def test_pull_skips_on_vps_node(agent, monkeypatch):
agent.node_name = node_agent.VPS_NODE_NAME
fake_run = MagicMock()
monkeypatch.setattr(node_agent.subprocess, "run", fake_run)
agent.pull_dispatched_actions()
fake_run.assert_not_called()
def test_pull_invokes_rsync_pull_direction(agent, monkeypatch):
captured = {}
def fake_run(cmd, **kwargs):
captured["cmd"] = cmd
return MagicMock(returncode=0, stderr="")
monkeypatch.setattr(node_agent.subprocess, "run", fake_run)
agent.pull_dispatched_actions()
cmd = captured["cmd"]
assert cmd[0] == "rsync"
assert "--remove-source-files" in cmd
# Source (remote VPS) comes before destination (local inbox) — pull, not push.
remote_arg = f"{node_agent.VPS_EVENTS_USER}@{node_agent.VPS_EVENTS_HOST}:"
assert any(a.startswith(remote_arg) for a in cmd[:-1])
assert cmd[-1] == str(agent._dispatch_inbox_dir()) + "/"
def test_pull_treats_empty_source_returncodes_as_non_error(agent, monkeypatch, caplog):
def fake_run(cmd, **kwargs):
return MagicMock(returncode=23, stderr="rsync: some vanished-source message")
monkeypatch.setattr(node_agent.subprocess, "run", fake_run)
with caplog.at_level("WARNING"):
agent.pull_dispatched_actions()
assert "Dispatch pull failed" not in caplog.text