"""Tests for observer handling of node-agent container-state events. Regression suite for the "dead/crash-looping container = silent" bug: the observer's process_event event→status/incident map handled service_recovered, service_healthy, service_unhealthy and healthcheck_failed but NOT containers_not_running (emitted by node-agent for exited/dead/crash-loop containers, and by stability-agent). A dead container therefore left the service at its last "healthy" status with no incident, so the supervisor saw no drift and produced no action — the operator was never alerted. Covers: 1. containers_not_running → status=unhealthy + incident with the right trigger_type 2. the incident's trigger_type is one the supervisor already routes to a container_restart (end-to-end reconcile, no supervisor change required) 3. service_healthy after containers_not_running → incident auto-resolved 4. container_restarting / container_state_unexpected → observational: no incident, status not flipped to unhealthy, but a trace is left behind 5. idempotency: reprocessing containers_not_running does not multiply incidents """ from __future__ import annotations import json import sys import time from pathlib import Path import pytest # Observer lives outside the control-plane package; add scripts/ to path. sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "scripts")) sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from observer.observer import Observer # noqa: E402 import supervisor as supervisor_module # noqa: E402 from supervisor import Supervisor, CONTAINER_RESTART_TRIGGERS # noqa: E402 # --------------------------------------------------------------------------- # Observer fixture — redirect every runtime path into a per-test tmp_path. # Mirrors the fixture in test_incident_lifecycle.py (incl. OBSERVER_STATE_FILE, # which is derived from STATE_DIR at import time). # --------------------------------------------------------------------------- @pytest.fixture def observer(tmp_path, monkeypatch) -> Observer: import observer.observer as obs_mod world = tmp_path / "world" state = tmp_path / "state" events = tmp_path / "events" logs = tmp_path / "logs" repo = tmp_path / "repo" for d in (world, state, events, logs, repo / "inventory", repo / "hosts"): d.mkdir(parents=True, exist_ok=True) (repo / "inventory" / "topology.yaml").write_text( "nodes:\n piha:\n roles: [infra]\n connectivity: {}\n" ) monkeypatch.setattr(obs_mod, "WORLD_DIR", world) monkeypatch.setattr(obs_mod, "STATE_DIR", state) monkeypatch.setattr(obs_mod, "EVENTS_DIR", events) monkeypatch.setattr(obs_mod, "LOGS_DIR", logs) monkeypatch.setattr(obs_mod, "INVENTORY_TOPOLOGY", repo / "inventory" / "topology.yaml") monkeypatch.setattr(obs_mod, "REPO_ROOT", repo) monkeypatch.setattr(obs_mod, "FAILED_EVENTS_DIR", state / "observer_failed_events") monkeypatch.setattr(obs_mod, "OBSERVER_STATE_FILE", state / "observer_checkpoint.json") return Observer() def _container_event(etype, *, node="piha", service="paperless", severity="high", ts=None, payload=None): return { "id": f"evt-{node}-{ts or int(time.time())}-{etype}-{service}", "type": etype, "node": node, "service": service, "severity": severity, "timestamp": ts or int(time.time()), "message": f"Container '{service}' {etype}", "payload": payload or {"container": service, "status": "exited"}, } # --------------------------------------------------------------------------- # 1. containers_not_running → unhealthy + incident # --------------------------------------------------------------------------- def test_containers_not_running_sets_unhealthy(observer): observer.process_event(_container_event("containers_not_running")) svc = observer.world_state["services"]["piha/paperless"] assert svc["status"] == "unhealthy" assert svc["incident_id"] is not None def test_containers_not_running_creates_incident_with_trigger_type(observer): ev = _container_event("containers_not_running") observer.process_event(ev) svc = observer.world_state["services"]["piha/paperless"] inc = observer.world_state["incidents"][svc["incident_id"]] assert inc["status"] == "active" assert inc["node"] == "piha" assert inc["service"] == "paperless" # trigger_type is the event type, which the supervisor routes to container_restart. assert inc["trigger_type"] == "containers_not_running" @pytest.mark.parametrize("etype", ["containers_not_running", "healthcheck_failed"]) def test_incident_trigger_type_is_recognized_by_supervisor(observer, etype): """The trigger_type the observer stamps MUST be one the supervisor routes to a container_restart — otherwise remediation silently never fires (or, for healthcheck_failed pre-etap-1, dead-ended in the broken redeploy path).""" observer.process_event(_container_event(etype)) svc = observer.world_state["services"]["piha/paperless"] inc = observer.world_state["incidents"][svc["incident_id"]] assert inc["trigger_type"] == etype assert inc["trigger_type"] in CONTAINER_RESTART_TRIGGERS def test_containers_not_running_after_healthy_transitions_to_unhealthy(observer): """A container that was healthy and then dies must flip to unhealthy — the exact real-world sequence that used to stay stuck at 'healthy'.""" observer.process_event(_container_event("service_healthy", severity="info")) assert observer.world_state["services"]["piha/paperless"]["status"] == "healthy" observer.process_event(_container_event("containers_not_running")) svc = observer.world_state["services"]["piha/paperless"] assert svc["status"] == "unhealthy" assert svc["incident_id"] is not None # --------------------------------------------------------------------------- # 2. End-to-end: observer output → supervisor generates container_restart # --------------------------------------------------------------------------- @pytest.mark.parametrize("etype", ["containers_not_running", "healthcheck_failed"]) def test_supervisor_generates_container_restart_from_observer_output( observer, tmp_path, monkeypatch, etype ): """Integration: run the real observer to produce world state for a failing container, then run the real supervisor.reconcile() against it and assert a container_restart action lands in pending/. healthcheck_failed routes to container_restart since etap 1 (2026-07-29, recon D14/D15): it used to fall through to redeploy, which is broken as wired in the executor, so those events dead-ended. A restart plausibly heals a failing healthcheck; redeploy returns once the executor works (etap 2).""" # 1. Observer ingests the failure event and writes world state to disk. observer.process_event(_container_event(etype)) observer._save_world() # 2. Point the supervisor at a fresh tmp tree; copy observer world output in. actions = tmp_path / "sup_actions" events = tmp_path / "sup_events" world = tmp_path / "sup_world" repo = tmp_path / "sup_repo" for d in (actions, events, world, repo / "hosts" / "piha"): d.mkdir(parents=True, exist_ok=True) import observer.observer as obs_mod for name in ("services.json", "incidents.json", "nodes.json"): src = obs_mod.WORLD_DIR / name if src.exists(): (world / name).write_text(src.read_text()) # Declare paperless as a desired service on piha so the drift loop considers it. (repo / "hosts" / "piha" / "services.yaml").write_text( "host: piha\nservices:\n paperless:\n role: kb\n exposure: lan\n" ) monkeypatch.setattr(supervisor_module, "ACTIONS_DIR", actions) monkeypatch.setattr(supervisor_module, "EVENTS_DIR", events) monkeypatch.setattr(supervisor_module, "WORLD_DIR", world) monkeypatch.setattr(supervisor_module, "REPO_ROOT", repo) sup = Supervisor() sup.reconcile() action_id = "container-restart-piha-paperless" action_path = actions / "pending" / f"{action_id}.json" assert action_path.exists(), "supervisor did not generate a container_restart" action = json.loads(action_path.read_text()) assert action["type"] == "container_restart" assert action["node"] == "piha" assert action["service"] == "paperless" assert action["payload"]["reason"] == etype # Must not ALSO fall through to the (broken until etap 2) redeploy path. assert not (actions / "pending" / "redeploy-piha-paperless.json").exists() # --------------------------------------------------------------------------- # 3. Recovery: service_healthy after containers_not_running auto-resolves # --------------------------------------------------------------------------- def test_service_healthy_resolves_containers_not_running_incident(observer): observer.process_event(_container_event("containers_not_running")) svc = observer.world_state["services"]["piha/paperless"] inc_id = svc["incident_id"] assert observer.world_state["incidents"][inc_id]["status"] == "active" observer.process_event(_container_event("service_healthy", severity="info")) svc = observer.world_state["services"]["piha/paperless"] assert svc["status"] == "healthy" assert svc["incident_id"] is None assert observer.world_state["incidents"][inc_id]["status"] == "resolved" # --------------------------------------------------------------------------- # 4. Observational events — no incident, no false remediation, but a trace # --------------------------------------------------------------------------- @pytest.mark.parametrize("etype", ["container_restarting", "container_state_unexpected"]) def test_observational_events_do_not_create_incident(observer, etype): observer.process_event(_container_event(etype, severity="low")) svc = observer.world_state["services"]["piha/paperless"] # No incident, and status is NOT flipped to unhealthy (would trigger a redeploy). assert svc.get("incident_id") is None assert svc["status"] != "unhealthy" assert not observer.world_state["incidents"] @pytest.mark.parametrize("etype", ["container_restarting", "container_state_unexpected"]) def test_observational_events_leave_a_trace(observer, etype): """They must be visible in world state, not vanish into the void.""" observer.process_event(_container_event(etype, severity="medium")) svc = observer.world_state["services"]["piha/paperless"] obs_note = svc.get("last_observation") assert obs_note is not None assert obs_note["type"] == etype def test_container_restarting_does_not_clear_existing_unhealthy(observer): """A container that is already unhealthy (dead) and then reports a transient 'restarting' must NOT be downgraded to healthy — the incident stands until a real service_healthy arrives.""" observer.process_event(_container_event("containers_not_running")) svc = observer.world_state["services"]["piha/paperless"] assert svc["status"] == "unhealthy" inc_id = svc["incident_id"] observer.process_event(_container_event("container_restarting", severity="low")) svc = observer.world_state["services"]["piha/paperless"] assert svc["status"] == "unhealthy" assert svc["incident_id"] == inc_id assert observer.world_state["incidents"][inc_id]["status"] == "active" # --------------------------------------------------------------------------- # 5. Idempotency — reprocessing must not multiply incidents # --------------------------------------------------------------------------- def test_repeated_containers_not_running_does_not_multiply_incidents(observer): ev = _container_event("containers_not_running", ts=1784000000) observer.process_event(ev) observer.process_event(ev) observer.process_event(ev) active = [i for i in observer.world_state["incidents"].values() if i["status"] == "active"] assert len(active) == 1 # Correlation bumps occurrence_count rather than opening new incidents. assert active[0]["occurrence_count"] == 3