126 plikow (md, yaml, sh, py) odwolywalo sie do sciezek sprzed migracji.
15 markdown-linkow [..](..) -> policzona sciezka WZGLEDNA wobec pliku
odsylajacego (wczesniej czesc z nich byla repo-root-relative i nie
rozwiazywala sie z katalogu, w ktorym lezala)
200 odwolan tekstowych (backticki, proza, yaml, importy w kodzie)
-> nowa sciezka repo-root-relative, zgodnie z konwencja repo
5 linkow rodzenstwa (gole nazwy plikow, np. "](DEPLOY.md)") — dzialaly
tylko w starym katalogu; przeliczone recznie
Objete m.in.: CLAUDE.md (scripts/onboard/README.md -> kb/runbooks/
node-onboarding-tool.md, docs/backlog.md -> kb/phases/backlog.md),
README.md, .claude/skills/, 20 session logow, kod jobow.
Ostatnie 5 odwolan pochodzi z tresci wciagnietej rebasem z origin/master
(session log 2026-07-31, override node-agenta na SOLARII, dwie pozycje
backlogu) — wskazywaly na docs/incidents/, docs/kb/modules/ i
services/narty27/README.md sprzed migracji.
Dodany wzajemny link miedzy kb/services/control-plane.md (stub kodu)
a kb/subsystems/control-plane.md (opis, deprecated) — dwa dokumenty o tym
samym systemie, latwe do pomylenia.
Weryfikacja na 790 plikach: 0 odwolan do starych sciezek,
0 martwych linkow markdown. Lint OKF: 190/190 plikow ZGODNE.
Co-Authored-By: Claude Opus 5 (1M context) <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 (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
|