homelab-codex-ws/services/node-agent/tests/test_control_plane_health.py
oskar dff76ecd30 fix(events): service_healthy tylko na transition + cleanup 358k backlog + retencja — glob zalewu paralizowal reconcile supervisora
- node-agent: service_healthy emitowany tylko przy przejsciu w stan zdrowy
  (per-service in-memory state), nie co cykl dla kazdego zdrowego serwisu.
  To samo dla control-plane HTTP probe. healthcheck_failed/containers_not_running/
  incydenty pozostaja emitowane bez zmian (realne sygnaly).
- node-agent: naprawiono _cleanup_control_plane_fs — istniejaca retencja
  eventow byla martwa od migracji observer_checkpoint.json na epoch-int
  (str(path) <= int rzucal TypeError, lapane cicho przez broad except).
  Teraz porownuje epoch-do-epoch i czysci wylacznie service_healthy/node_health
  starsze niz checkpoint + 3-dniowy bufor; healthcheck_failed/incydenty/ha_*
  zachowane bezterminowo.
- scripts/maintenance/cleanup_event_backlog.py: jednorazowy skrypt do
  bezpiecznego czyszczenia backlogu na VPS (dry-run domyslnie, --apply
  do usuniecia). Ten sam warunek: typ szumu + starsze niz checkpoint + 1h bufor.
2026-07-16 20:20:17 +02:00

65 lines
2.3 KiB
Python

"""Tests for NodeAgent._check_control_plane_health() transition-only emission.
Regression coverage for the event-flood fix: the VPS control-plane HTTP probe
used to emit service_healthy every single cycle it was reachable, which was
part of the same flood as check_containers()'s per-service confirmation.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import node_agent
def run_probe(agent, monkeypatch, *, status=200, raise_exc=None):
"""Run _check_control_plane_health() with urlopen mocked, capturing emitted events."""
emitted = []
def fake_emit(event_type, severity, service, message, payload=None):
emitted.append((event_type, severity, service, message, payload or {}))
monkeypatch.setattr(agent, "emit_event", fake_emit)
if raise_exc is not None:
def fake_urlopen(*a, **kw):
raise raise_exc
else:
resp = MagicMock()
resp.status = status
def fake_urlopen(*a, **kw):
return resp
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
agent._check_control_plane_health()
return emitted
def test_reachable_emits_service_healthy(agent, monkeypatch):
events = run_probe(agent, monkeypatch, status=200)
assert len(events) == 1
assert events[0][0] == "service_healthy"
assert events[0][2] == "control-plane"
def test_reachable_second_cycle_is_silent(agent, monkeypatch):
run_probe(agent, monkeypatch, status=200)
events = run_probe(agent, monkeypatch, status=200)
assert events == []
def test_unreachable_reemits_service_unhealthy_every_cycle(agent, monkeypatch):
"""service_unhealthy is a real fault signal — must stay unconditional."""
run_probe(agent, monkeypatch, status=200)
events = run_probe(agent, monkeypatch, raise_exc=OSError("connection refused"))
assert events[0][0] == "service_unhealthy"
events = run_probe(agent, monkeypatch, raise_exc=OSError("connection refused"))
assert events[0][0] == "service_unhealthy"
def test_recovery_after_unreachable_reemits_service_healthy(agent, monkeypatch):
run_probe(agent, monkeypatch, raise_exc=OSError("connection refused"))
events = run_probe(agent, monkeypatch, status=200)
assert len(events) == 1
assert events[0][0] == "service_healthy"