topology.yaml (per its own rule that hosts/*/services.yaml are authoritative, recon F20.1): - add status field; chelsty-infra + chelsty-ha -> dormant (site hardware down since ~2026-06-01, revival planned) - add lustro as a full active node (runs node-agent, ships events, F20.11) - drop per-node service lists (vps list contradicted hosts/vps, F20.1; piha/solaria lists were stale too, F20.8/F20.9) — node-level truth only - deployment.mode pull -> push: every deploy script SSH-pushes from saturn (F20.10) Dormant semantics in code: - observer (scripts/observer/observer.py): loads status from topology; _prune_stale_world skips dormant nodes — last-known world state stays frozen, no node_offline/node_stale/node_online events emitted - supervisor (services/control-plane/src/supervisor.py): reloads dormant set each reconcile; dormant hosts' services excluded from desired state (existing pending actions auto-cancel via service_removed_from_desired_state), disk_cleanup skipped, node/HA events from dormant nodes not routed to alerts Tests: services/control-plane/tests/test_dormant_nodes.py (9 cases); full suite 145 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
198 lines
6.6 KiB
Python
198 lines
6.6 KiB
Python
"""Dormant-node handling (topology `status: dormant`, etap 0 truth cleanup).
|
|
|
|
Contract (docs/architecture/ARCHITEKTURA.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
|