"""Dormant-node handling (topology `status: dormant`, etap 0 truth cleanup). Contract (kb/decisions/architektura-2026-07-28.md): a dormant node keeps its last-known world state, the observer emits NO liveness events for it, and the supervisor generates NO actions of any kind for it. """ from __future__ import annotations import json import sys import time from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).parent.parent / "src")) sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "scripts")) import observer.observer as obs_mod from observer.observer import Observer import supervisor as supervisor_module from supervisor import Supervisor TOPOLOGY_YAML = ( "nodes:\n" " piha:\n" " status: active\n" " roles: [infra]\n" " oldnode:\n" " status: dormant\n" " dormant_reason: \"hardware down\"\n" " roles: [remote]\n" ) # --------------------------------------------------------------------------- # Observer # --------------------------------------------------------------------------- @pytest.fixture def obs_paths(tmp_path, monkeypatch): 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(TOPOLOGY_YAML) 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") monkeypatch.setattr(obs_mod, "PROM_SHADOW_URL", "") monkeypatch.setattr(obs_mod, "SHADOW_LOG_DIR", logs / "observer") return tmp_path def test_observer_loads_dormant_set_from_topology(obs_paths): obs = Observer() assert obs.dormant_nodes == {"oldnode"} def test_observer_freezes_dormant_node_and_emits_nothing(obs_paths): """A dormant node whose heartbeats stopped long ago must NOT be reclassified (stale→dead would normally fire node_offline).""" obs = Observer() stale_age = int(time.time()) - 999999 obs.world_state["nodes"] = { "oldnode": {"status": "stale", "liveness": "stale", "last_seen": stale_age}, "piha": {"status": "unknown", "last_seen": int(time.time())}, } obs._prune_stale_world() frozen = obs.world_state["nodes"]["oldnode"] assert frozen["liveness"] == "stale" # unchanged, despite dead-level age assert frozen["status"] == "stale" # unchanged # No liveness event file was written for the dormant node. assert not list((obs_paths / "events").glob("**/evt-oldnode-*")) # The active node is still processed normally. assert obs.world_state["nodes"]["piha"]["liveness"] == "fresh" def test_observer_keeps_dormant_node_in_world_state(obs_paths): """Dormant nodes are in the inventory, so pruning must not remove them.""" obs = Observer() obs.world_state["nodes"] = {"oldnode": {"status": "offline", "last_seen": 1}} obs._prune_stale_world() assert "oldnode" in obs.world_state["nodes"] # --------------------------------------------------------------------------- # Supervisor # --------------------------------------------------------------------------- @pytest.fixture def sup(tmp_path, monkeypatch): actions = tmp_path / "actions" events = tmp_path / "events" world = tmp_path / "world" state = tmp_path / "state" repo = tmp_path / "repo" for d in (actions, events, world, state, repo / "inventory", repo / "hosts" / "oldnode"): d.mkdir(parents=True, exist_ok=True) (repo / "inventory" / "topology.yaml").write_text(TOPOLOGY_YAML) (repo / "hosts" / "oldnode" / "services.yaml").write_text( "host: oldnode\nservices:\n some-svc:\n role: x\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) s = Supervisor() return s def _pending(tmp_path): return list((tmp_path / "actions" / "pending").glob("*.json")) def test_supervisor_loads_dormant_set(sup): sup._load_dormant_nodes() assert sup.dormant_nodes == {"oldnode"} def test_dormant_services_excluded_from_desired_state(sup): sup._load_dormant_nodes() sup._load_desired_state() assert "oldnode/some-svc" not in sup.desired_state["services"] def test_reconcile_generates_no_actions_for_dormant_node(sup, tmp_path): """Full reconcile: missing service + high disk pressure on a dormant node must produce zero pending actions.""" world = tmp_path / "world" (world / "nodes.json").write_text(json.dumps( {"oldnode": {"status": "offline", "disk_pressure": "high"}} )) (world / "services.json").write_text("{}") (world / "incidents.json").write_text("{}") sup.reconcile() assert _pending(tmp_path) == [] def test_node_event_from_dormant_node_not_routed(sup, tmp_path): sup._load_dormant_nodes() event = { "id": "evt-oldnode-1-node_offline-node", "type": "node_offline", "node": "oldnode", "payload": {"affected_node": "oldnode"}, } (tmp_path / "events" / f"{event['id']}.json").write_text(json.dumps(event)) sup._process_ha_events() assert _pending(tmp_path) == [] def test_ha_event_from_dormant_node_not_routed(sup, tmp_path): sup._load_dormant_nodes() event = { "id": "evt-oldnode-2-ha_entity_unavailable_long-homeassistant", "type": "ha_entity_unavailable_long", "node": "oldnode", "payload": {}, } (tmp_path / "events" / f"{event['id']}.json").write_text(json.dumps(event)) sup._process_ha_events() assert _pending(tmp_path) == [] def test_active_node_events_still_routed(sup, tmp_path): """Sanity: the dormant guard must not swallow active-node events.""" sup._load_dormant_nodes() event = { "id": "evt-piha-3-node_stale-node", "type": "node_stale", "node": "piha", "payload": {"affected_node": "piha"}, } (tmp_path / "events" / f"{event['id']}.json").write_text(json.dumps(event)) sup._process_ha_events() assert len(_pending(tmp_path)) == 1