Executor nie ma klienta ssh ani klucza do floty (uid 1000 homelab, brak ~/.ssh, brak resolucji nazw wezlow) — container_restart przez subprocess ssh failowal w 6ms na kazdej probie. Zamiast dodawac SSH do executora, kierunek jest odwrocony: executor zapisuje zlecenie do /opt/homelab/actions/dispatch/<node>/<action_id>.json, a node-agent na docelowym wezle (ktory ma dzialajacy docker.sock i juz ma klucz SSH do VPS uzywany do shippingu eventow) sam je odbiera i wykonuje lokalnie. - executor: _dispatch_container_restart pisze zlecenie zamiast ssh; _reconcile_running_actions konsumuje zwrotne action_result eventy i timeoutuje akcje bez odpowiedzi (ACTION_TIMEOUT_SECS, domyslnie 300s). redeploy/disk_cleanup/alert_only bez zmian. - node-agent: nowy krok w petli — rsync-pull wlasnej podkatalogu dispatch z VPS (ten sam klucz co _ship_events_to_vps, w przeciwnym kierunku; no-op na VPS, gdzie katalog jest lokalny), walidacja (node_name, whitelist tylko container_restart, odmowa restartu wlasnego kontenera), wykonanie przez docker SDK, raport jako event action_result (istniejacy kanal shippingu). Idempotencja przez znacznik w /opt/homelab/state/processed-actions/. - 26 nowych testow (10 executor, 16 node-agent), pelny suite obu serwisow 183/183 zielony. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
251 lines
8.7 KiB
Python
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 docs/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
|