--- okf: "0.1" type: audit visibility: private status: active updated: 2026-07-06 as_of: 2026-07-06 links: [] --- # Prometheus liveness cutover — recon starego toru (2026-07-06) Read-only recon przed cutoverem liveności floty na Prometheus `up{}`. Mapa obecnego toru: node-agent → eventy → rsync → observer → world_state → panel/supervisor, z dokładnymi punktami przełączenia i etapowym planem. Zero zmian w kodzie — tylko ten dokument. **Korekta ścieżki względem założeń taska:** observer NIE żyje w `services/control-plane/src/observer/` — to pojedynczy moduł `scripts/observer/observer.py` (656 linii), a maszyna stanów liveności jest wydzielona do `services/control-plane/src/liveness.py` (152 linie), importowanego przez observer i oba panele. **Trzy rzeczy, które wozi stara rura** (cutover dotyczy tylko #1): 1. **Liveność węzła** — heartbeat `node_health` → `last_seen` → TTL → `online/stale/offline`. To zastępuje Prometheus `up{}`. 2. **Bogate eventy aplikacyjne** — `containers_not_running`, `healthcheck_failed`, `disk_pressure`, `service_unhealthy`… Prometheus tego NIE wozi. Muszą zostać. 3. **world_state / panel / incydenty** — observer buduje je z eventów; po cutoverze panel musi brać liveność z nowego źródła, ale incydenty/serwisy dalej z eventów. Kluczowe odkrycie recon: **heartbeat `node_health` jest jednocześnie nośnikiem metryk zasobów (disk/mem/cpu) do panelu i supervisora** — więc cutover to zmiana *źródła klasyfikacji liveności*, a NIE wyłączenie heartbeatu ani rsynca. Prawie nic nie jest do „odłączenia"; stara rura zostaje fizycznie w całości. --- ## A. NODE-AGENT (`services/node-agent/`) ### A.1 Emitowane typy eventów Wszystko przez `NodeAgent.emit_event()` — `services/node-agent/src/node_agent.py:154-177`. Koperta: `id, timestamp (int epoch), date, type, severity, node, service, message, payload` (`node_agent.py:162-172`); `id = evt----` (`:161`). | typ | severity | warunek emisji | miejsce | klasa | |---|---|---|---|---| | `node_health` | info | **co cykl, bezwarunkowo** (payload: `disk_pct, mem_pct, cpu_pct`) | `node_agent.py:636-640` | **#1 czysty heartbeat + nośnik metryk** | | `disk_pressure` | high/medium | disk ≥ 85% / ≥ 75% | `:201-205`, `:208-212` | #2 bogaty | | `high_memory` | high/medium | mem ≥ 95% / ≥ 85% | `:236-240` | #2 bogaty | | `high_cpu` | medium | CPU ≥ 90% | `:266-267` | #2 bogaty | | `containers_not_running` | high | kontener `exited/dead` z restart policy | `:338-343` | #2 bogaty | | `healthcheck_failed` | high | kontener running, docker health `unhealthy` | `:348-352` | #2 bogaty | | `service_healthy` | info | kontener running (per kontener, co cykl) | `:359-364` | #2 (potwierdzenie per-serwis) | | `service_healthy/unhealthy` (control-plane) | info/high | tylko VPS: sonda `http://localhost:18180/summary` | `:588-604` | #2 bogaty | node-agent **NIE** emituje `docker_api_error`, `mqtt_unreachable` ani eventów tailscale — to repertuar stability-agenta (osobna rura, patrz A.4). Błąd Docker API w node-agent to tylko `logger.error`, bez eventu (`:311`, `:367`). ### A.2 Zapis eventów - `RUNTIME_PATH` default `/opt/homelab` (`node_agent.py:57`), `EVENTS_DIR = /opt/homelab/events` (`:59`). - Zapis do `EVENTS_DIR//.json` — **płaski katalog per-node, jeden plik JSON na event, bez partycji datą, nie JSONL** (`_node_events_dir` `:147-148`, zapis `:173-175`). Odmienna konwencja niż `scripts/lib/events.py:7,21,25` / `events.sh` (date-partitioned `events/YYYY-MM-DD//…`), które też lądują w tym samym drzewie — observer czyta oba (glob rekurencyjny, sekcja B.1). ### A.3 Transport na VPS `_ship_events_to_vps()` — `node_agent.py:530-566`, wołane na końcu **każdego cyklu** (`:642`), czyli co `CHECK_INTERVAL` (default 60 s, `docker-compose.yml:29`; wszystkie overridy hostów ustawiają 60): ``` rsync -az --remove-source-files -e "ssh -F /dev/null -o StrictHostKeyChecking=no ..." \ /opt/homelab/events// oskar@100.95.58.48:/opt/homelab/events// ``` - **Push z węzła na VPS**, nie pull; VPS jest sinkiem i nie shipuje (guard `:538-539`). - `--remove-source-files` (`:546`) — po udanym pushu eventy znikają lokalnie; bufor lokalny istnieje tylko przez okres braku łączności (offline-first dla LTE). - Klucz SSH montowany z hosta (np. `hosts/piha/runtime/node-agent/docker-compose.override.yml`, wolumen `~/.ssh → /home/homelab/.ssh:ro`); timeout 30 s, błąd nie-fatalny (`:560-566`). **Konsekwencja dla liveności:** `last_seen` węzła w world_state to w praktyce „ostatni event, który *fizycznie dojechał* na VPS" — mierzy łącznie: node żyje + Docker żyje + node-agent żyje + SSH/Tailscale do VPS działa. Prometheus `up{}` mierzy węższą rzecz: node_exporter odpowiada na scrape z VPS. Różnica semantyk jest realna (np. padnięty node-agent przy żywym node = stary tor mówi „dead", Prometheus mówi „up") i musi wyjść w parallel-run jako *znana* klasa rozbieżności. ### A.4 stability-agent — osobna rura (nie dotyczy cutoveru) `services/stability-agent/src/stability_agent.py`: pisze `events/YYYY-MM-DD//events.jsonl` (append, `:22,47-53`) i publikuje do **Redis na piha** (`100.108.208.3`, `:57-70,289-344`). Observer i supervisor globują tylko `*.json` (`observer.py:596`, `supervisor.py:538`), więc **`events.jsonl` stability-agenta w ogóle nie zasila world_state** — jego eventy (`docker_api_error`, `mqtt_unreachable`…) żyją w torze Redis. Cutover liveności go nie dotyka. Nie występuje w żadnym `hosts/*/services.yaml` (tylko node-agent tam jest); `service.yaml:3` mówi `owner_node: chelsty`, a deploy-skrypt obsługuje piha/chelsty/solaria/vps. --- ## B. OBSERVER (`scripts/observer/observer.py` + `services/control-plane/src/liveness.py`) ### B.1 Czytanie eventów - `EVENTS_DIR = RUNTIME_PATH/events` (`observer.py:57-58`); `WORLD_DIR = /opt/homelab/world` (`:61`). - `run_once`: rekurencyjny glob `EVENTS_DIR/**/*.json`, posortowany (`observer.py:596`). - Checkpoint per-katalog-węzła: `/opt/homelab/state/observer_checkpoint.json` (`:62`, format `{"node_checkpoints": {...}}` `:77`, save `:193-197`); plik „nowy" gdy ścieżka > checkpoint danego katalogu (porównanie stringów, `:604-606`, awans tylko do przodu `:631-632`). Uszkodzone eventy → kwarantanna `state/observer_failed_events//` (`:100-118`, `:638`). - Pętla co **5 s**, hardcoded (`:644-648`). Heartbeat `state/observer.heartbeat` co cykl (`:586-590`) — na nim wisi compose healthcheck (`services/control-plane/docker-compose.yml:30-35`). - Uruchomienie: kontener `observer` na VPS, `python /repo/scripts/observer/observer.py` (`services/control-plane/docker-compose.yml:18-35`; `hosts/vps/services.yaml:17-34`). ### B.2 Wyprowadzanie liveności z eventów — DOKŁADNE punkty przełączenia Dwie warstwy zapisu + jedna warstwa read-time: **(1) Event-driven, `process_event` (`observer.py:420-470`):** - **`observer.py:436`** — `world_state["nodes"][node]["last_seen"] = timestamp` dla **KAŻDEGO** eventu z węzła, bezwarunkowo. Jedyne miejsce zapisu `last_seen` z ingestu. ⟵ *switch point #1* - `observer.py:438-441` — `node_online`→`status="online"`, `node_offline`→`"offline"`. - `observer.py:443-453` — `node_health`: `status="online"` (`:446`) + metryki `disk_usage_pct/mem_usage_pct/cpu_usage_pct` (`:447-451`) + kasowanie `disk_pressure` gdy disk < 75 (`:452-453`). ⟵ *switch point #2 (część statusowa); część metryczna MUSI zostać* **(2) Autorytatywna klasyfikacja TTL, `_prune_stale_world` (`observer.py:300-326`)** — działa **co cykl, także bez nowych eventów** (`run_once:608-611`): - `observer.py:312-314` — `compute_liveness(last_seen, now, ttls_for(node, roles))`. ⟵ *switch point #3 — GŁÓWNE miejsce podmiany źródła* - `:315-317` — `UNKNOWN` (brak `last_seen`) → nie dotykaj statusu. - `:319-322` — zapis `node_info["liveness"]` i `node_info["status"] = liveness_to_status(...)`. - `:325-326` — na przejściu tieru → `_emit_node_transition`. **Maszyna stanów — `services/control-plane/src/liveness.py`** (single source of truth, importowana przez observer + `operator_ui.py` + `webui/web.py`, patrz docstring `:1-18`): - Tiery `FRESH/STALE/DEAD/UNKNOWN` (`liveness.py:27-30`). - TTL: default fresh ≤ 180 s / dead > 600 s (`:36-39`); remote (chelsty-*) 900/3600 s (`:43-46`); wybór w `ttls_for` (`:80-85`, `REMOTE_NODES={"chelsty-infra","chelsty-ha"}`, `REMOTE_ROLES={"remote"}` `:49-50`). Env: `LIVENESS_TTL_FRESH/DEAD`, `LIVENESS_REMOTE_TTL_FRESH/DEAD`. - `compute_liveness` (`:88-103`): wiek `now - parse_ts(last_seen)` → tier. - `liveness_to_status` (`:106-108`): fresh→online, stale→stale, dead→offline, UNKNOWN→None (nigdy nie zgaduje). - `parse_ts` (`:64-77`): epoch int/float (node-agent) i ISO string (events.py) — oba formaty realnie występują w `last_seen`. **(3) Syntetyczne eventy przejść — `_emit_node_transition` (`observer.py:199-254`):** DEAD→`node_offline` (high), STALE→`node_stale` (warning), powrót do FRESH→`node_online` (info) (`:217-224`); tag `source:"observer"` (`:237`), zapis do `EVENTS_DIR//` (`:248-250`). Guard re-ingestu: `run_once` pomija eventy `source=="observer"` (awansuje checkpoint, nie woła `process_event`), żeby nie wskrzeszać martwego węzła (`observer.py:620-628`). **Po cutoverze ten mechanizm zostaje — zmienia się tylko to, co zasila klasyfikację przejść.** Konsumentem jest supervisor (sekcja C.1). ### B.3 Pochodzenie pól world_state (`_save_world`, `observer.py:392-418`) | plik | pola z liveności (#1) | pola z bogatych eventów (#2) | inne | |---|---|---|---| | `nodes.json` | `last_seen` (`:436`), `status` (`:438-446` + `:320-322`), `liveness` (`:319`) | `disk_usage_pct/mem_usage_pct/cpu_usage_pct` (`:447-451,459,465,470`), `disk_pressure` (`:458`), `memory_pressure` (`:464`), `cpu_pressure` (`:469`) | `roles` z `inventory/topology.yaml` (`:434`, `:126-130`) | | `services.json` | — | `status` healthy/unhealthy, `last_check`, `incident_id` z `service_*`/`healthcheck_failed` (`:474-499`, `:560`) | | | `deployments.json` | — | `deployment_*` po `correlation_id` (`:502-525`) | | | `incidents.json` | — (**liveność węzła NIE tworzy incydentów** — tylko syntetyczne eventy → supervisor alert) | `service_unhealthy`/`healthcheck_failed`/`deployment_failed` → `_handle_incident` (`:529-560`, `:570-582`) | auto-resolve (`:342-377`), prune 7 d (`:384-390`) | | `runtime-summary.json` | pośrednio (`node_count`) | `active_incidents_count`, `status` nominal/degraded (`:393-404`) | `last_update` — na tym wisi brain-watchdog | Prune: węzły spoza `topology.yaml` usuwane (`:272-276`) — ważne przy dodawaniu węzłów do Prometheusa: nazwa z labelki `node:` musi istnieć w topology. ### B.4 Świadomość Prometheusa w observerze **Zero.** Grep `prometheus|up\{|api/v1|9090|node_exporter` po `scripts/observer/`, `services/control-plane/src/` i testach — brak trafień (poza niepowiązanym `index.html:688`). Liveność liczona wyłącznie z freshness eventów. --- ## C. KONSUMENCI world_state / liveności ### C.1 Supervisor (`services/control-plane/src/supervisor.py`) - Czyta `world/{services,nodes,incidents}.json` w `_load_actual_state` (`:197-248`); pusty/ucięty plik → cały cykl reconcile pomijany (`:282-283`). - **Z `nodes.json` używa TYLKO `disk_pressure`** (`reconcile:315-319` → `disk_cleanup` recommendation; `NO_DISK_CLEANUP_NODES` = chelsty-*, `:44`). **Żadna decyzja nie czyta `status`/`liveness`/`last_seen`.** - **Pętla driftu/remediacji (`:288-308`) nie gate'uje na liveność węzła** — porównuje desired vs `services.json` i generuje redeploy/container_restart niezależnie od tego, czy węzeł żyje. (Jedyny hamulec: `monitor: false` w `services.yaml`, `:182-186`.) - Liveność węzłów konsumuje **event-driven**: `NODE_ALERT_EVENTS = {node_offline, node_stale, node_online}` (`:94`) → `_route_node_event` (`:742-783`) → akcja `alert_only` z dedupem po `action_id` i cooldownem 1 h (`NODE_ALERT_COOLDOWN` `:95`). Komentarz `:88-94`: offline node = alert-only, bo nie da się go zrestartować. - **Wniosek cutoverowy:** supervisor przeżywa zniknięcie pól liveności z `nodes.json` bez zmian; łamie się dopiero, gdy przestaną powstawać syntetyczne eventy przejść (cisza zamiast alertu node-down). `disk_pressure` i cały tor remediacji service-level = bogate eventy, nietykalne. ### C.2 Panel operator-ui (agents.okit.pl) — `services/control-plane/src/operator_ui.py` - `/nodes` → `current_nodes` (`:73-99`): czyta `nodes.json`, per węzeł liczy `health` przez `liveness.node_health` (`_node_health` `:62-70`) — **read-time safety net**: worse(persisted `status`, freshness z `last_seen`) (`liveness.py:116-138`). Chroni przed zamrożonym observerem (frozen `nodes.json`) — freshness dalej się starzeje. - `/services` → `current_services` (`:102-147`): czyta `services.json` **i** `nodes.json`, liczy `compute_liveness` per węzeł (`:116-123`) i kaskadę `degrade_for_node` (`:132-133`; `liveness.py:141-151`) — serwis na martwym węźle nigdy nie jest nominal. - `/incidents` (`:171-192`), `/summary` (`:199-216`, `stale` gdy `last_update` > 60 s), `/events` (`:225-256`, czyta wprost `EVENTS_DIR`, nie world). - Frontend `index.html`: badge `node.health` + `last_seen` (`:539,545`), karty topologii kolorowane po `health` (`:626-641`). - **Wniosek cutoverowy:** panel jest NAJWIĘKSZYM konsumentem pól liveności. Bez `status`/`last_seen` w `nodes.json`: health „unknown", pusta kolumna last_seen, utrata safety netu i kaskady serwisowej. **Pola muszą być dalej pisane** — tylko zasilane z Prometheusa. Uwaga: safety net liczy z `last_seen`, więc dopóki heartbeat eventowy płynie, `last_seen` jest świeży i nie kłóci się z prom-statusem; gdyby heartbeat kiedyś wyłączyć, `node_health()` w `liveness.py` musi najpierw nauczyć się nowego źródła freshness (pułapka opisana w F, etap 4). ### C.3 Drugi panel — `services/agent-system/webui/web.py` (piha, :18180) To samo co C.2, z degradacją: `liveness.py` bind-mountowane do `/app/liveness.py` (`agent-system/docker-compose.yml:19`); przy braku modułu `_LIVENESS_OK=False` i fallback do samego `status` (`web.py:14-18, 89-92`). Dane lustrzane przez `runtime-materializer` (`materializer.py:72-107` — mirror endpointów HTTP control-plane do lokalnych `world/*.json` na piha). Ten sam wymóg: pola `status`/`last_seen` muszą żyć. ### C.4 Telegram bot (`services/agent-system/telegram-bot/bot.py`) Nie czyta `world/*.json` bezpośrednio. Dwa tory: (1) skan `/opt/homelab/actions/pending/` (`:95-115`) — tędy przychodzi node-down alert (akcja `alert_only` supervisora); (2) komendy przez HTTP control-plane (`fetch_api` `:27-40`): `/nodes` (`:258-275`, renderuje `health`/`status`/`last_seen`), `/unhealthy` (`:302-334`), `/summary`, `/incidents`. Łamie się dokładnie tam, gdzie panel. ### C.5 brain-watchdog (`services/brain-watchdog/src/brain_watchdog/main.py`, na PIHA) **W pełni niezależny od world_state-liveności** — to już działający „nowy tor": - Tor 1: `check()` (`:141-170`) — GET `{CONTROL_PLANE_URL}/summary`, staleness liczona lokalnie z `last_update` vs 600 s; wykrywa martwy mózg (observer/world_state). - Tor 2: `check_prometheus_alerts()` (`:78-105`) — GET `{PROMETHEUS_URL}/api/v1/alerts`, filtruje `state=="firing"`, klucz `alertname:node`; `handle_prometheus_alerts` (`:108-138`) → Telegram raz na firing + recovery. Tylko `/api/v1/alerts`, **nie** `/api/v1/query`. - `PROMETHEUS_URL` opcjonalne (`env.example:8-11`, przykład `http://100.95.58.48:9090`). ### C.6 Pozostali - **Executor** (`executor.py`): zero zależności od world — czyta tylko `actions/approved/` (`:45-57`) i wykonuje po SSH (`:59-137`). Uwaga poboczna: wykona restart nawet na węźle, który observer ma za offline (brak gate'u). - **Onboarding** `scripts/onboard/steps/50-verify.sh:110-142` — **twardy gate na `nodes.json` `status` ∈ {online, offline}** (krok 4/4). Musi przeżyć cutover (przeżyje, jeśli pole `status` dalej pisane). - `scripts/deploy/verify-agent-fleet.sh:23-59` — liveness z Redisa (tor stability-agenta) + curl `/summary`,`/nodes`. ### C.7 Macierz: co się łamie | konsument | pola liveness w nodes.json | syntetyczne node_offline/stale/online | brain-watchdog/Prometheus | |---|---|---|---| | supervisor remediacja | — (tylko `disk_pressure`) | — | — | | supervisor node-alert → Telegram | — | **TAK — jedyny konsument** | — | | operator-ui `/nodes`,`/services` | **TAK** (status, last_seen, kaskada) | tylko feed `/events` | — | | webui (piha) | **TAK** (z fallbackiem) | feed eventów | — | | telegram bot `/nodes`,`/unhealthy` | TAK (przez HTTP) | przez actions/pending | — | | brain-watchdog | — | — | **TAK** (już działa) | | executor | — | — | — | | onboard 50-verify | **TAK** (`status` gate) | — | — | --- ## D. PROMETHEUS (`services/fleet-prometheus/`) — co jest, czego brakuje ### D.1 Co JUŻ jest - Instancja na **VPS** (`service.yaml:3`), `prom/prometheus:v3.5.0`, port **tylko na Tailscale IP**: `${TAILSCALE_BIND_IP}:9090:9090` (`docker-compose.yml:31`; `env.example:10` = `100.95.58.48`). `mem_limit: 512m`, `oom_score_adj: 200` (`:40-41`) — **ubijalny przed control-plane (−900)**. Retencja 15 d / 2 GB (`:14-15`). - Scrape (`prometheus.yml`): global 15 s (`:10`), external label `fleet: homelab-codex` (`:11-12`). Job `fleet-node` (`:36-52`) — po jednej labelce `node:` na target: `vps` (host.docker.internal:9100), `piha` (100.108.208.3), `solaria` (100.100.231.104), `lustro` (100.99.85.73). Świadomie NIE scrape'owane (`:54-60`): saturn (laptop), chelsty/chelsty-infra (exporter DOWN po LTE, 2026-06-26). - Reguła `NodeDown` (`rules/liveness.yml:21-28`): `up{node=~"vps|piha"} == 0`, `for: 5m`, severity critical. solaria/lustro świadomie wykluczone (`:12-16` — planned power-off; docelowo anomaly detection, `docs/backlog.md:390-406`). Bez Alertmanagera by design (`:3-7`) — delivery = brain-watchdog poll `/api/v1/alerts`. - node_exporter w repo tylko dla VPS (`services/node_exporter/`, network_mode: host, `hosts/vps/services.yaml:36-43`). Exportery na piha/solaria/lustro **nie mają definicji w repo** — istnieją poza GitOps. *(do weryfikacji na żywo: jak są postawione i czy wstaną po reboocie)* ### D.2 Czego brakuje, żeby observer pytał Prometheus o liveność 1. **Klient HTTP w observerze** — dziś zero (B.4). Potrzebne: `GET http://100.95.58.48:9090/api/v1/query?query=up{job="fleet-node"}` (stdlib `urllib`, jak w brain-watchdog). Observer to bridged kontener na VPS bez wspólnej sieci docker z fleet-prometheus — musi iść przez Tailscale IP hosta, bo port nie jest na `0.0.0.0`. *(routing bridged→tailscale-IP-hosta do weryfikacji na żywo; brain-watchdog z pihy działa przez mesh — sesja 2026-06-30)* 2. **Konfiguracja**: env `PROMETHEUS_URL` (opcjonalny → fail-open) w serwisie `observer` w `services/control-plane/docker-compose.yml:18-35`. 3. **Mapowanie zbiorów węzłów**: Prometheus zna {vps, piha, solaria, lustro}; topology (`inventory/topology.yaml:34-116`) zna 7: + saturn, chelsty-infra, chelsty-ha. Observer musi wiedzieć, dla których węzłów `up{}` jest autorytatywne, a dla których zostaje freshness eventowa → **liveność hybrydowa per-node** (lista w konfigu lub dynamicznie: „węzeł ma serię `up`→Prometheus, nie ma→eventy"). 4. **Mapowanie semantyki**: `up` jest binarne (1/0/brak serii), tier `stale` nie ma bezpośredniego odpowiednika. Propozycja: `up==1`→fresh; `up==0` przez < N s→stale, ≥ N s→dead (N z obecnych TTL); brak serii→UNKNOWN (nie dotykaj — dokładnie dzisiejsza semantyka `liveness.py:96-97`). Alternatywa odporniejsza na restart Prometheusa: `time() - timestamp(up)` jako „prom-last_seen" wpuszczone w istniejące `compute_liveness` — minimalna zmiana, te same TTL-e i te same przejścia. 5. **Watchdog na sam Prometheus**: po cutoverze Prometheus staje się źródłem prawdy, a nikt go nie pilnuje (brain-watchdog pilnuje mózgu i *konsumuje* alerty, ale nie alarmuje, gdy Prometheus umrze — poll po prostu cichnie). Potrzebny alert/telegram na błąd pollingu lub na `up{job="prometheus"}`. --- ## E. LUKA POKRYCIA Węzły z `inventory/topology.yaml:34-116` (7): saturn, piha, solaria, vps, chelsty-infra, chelsty-ha, lustro. | węzeł | Prometheus scrape | NodeDown alert | node-agent (stary tor) | wniosek cutoverowy | |---|---|---|---|---| | vps | TAK (`prometheus.yml:40-42`) | TAK | TAK | pełne pokrycie oboma — kandydat na cutover | | piha | TAK (`:44-46`) | TAK | TAK | jw. | | solaria | TAK (`:47-49`) | NIE (`liveness.yml:12-16`) | TAK | cutover statusu OK; alert dalej brak (świadomie, anomaly detection later) | | lustro | TAK (`:50-52`) | NIE | TAK (bez stability-agenta) | jw. | | **chelsty-infra** | **NIE** (`:57-60`, exporter DOWN po LTE) | NIE | **TAK** (remote TTL 900/3600, `liveness.py:43-50`) | **tylko stary tor — cutover totalny zostawiłby go bez liveności** | | chelsty-ha | NIE | NIE | NIE (`hosts/chelsty-ha/services.yaml:6-12`, `monitor: false`) | już dziś bez liveności (pośrednio przez MQTT chelsty-infra) — cutover nic nie zmienia | | saturn | NIE (`:55`, laptop) | NIE | NIE (brak `hosts/saturn/services.yaml`, `docs/backlog.md:423`) | już dziś bez liveności — cutover nic nie zmienia | **Chelsty offline ~34 dni — jak traktuje go stara rura:** eventy buforują się lokalnie (rsync fail = non-fatal, `node_agent.py:560-566`), `last_seen` na VPS zamrożone sprzed outage'u → `compute_liveness` z remote TTL (dead > 3600 s) klasyfikuje DEAD → `status="offline"` w `nodes.json`, panel pokazuje error; syntetyczny `node_offline` poszedł raz przy przejściu (potem cooldown/dedup). Po powrocie LTE: zaległe eventy dojeżdżają rsynciem, `last_seen` skacze do przodu, tier wraca, `node_online` się emituje. **Prometheus tego węzła w ogóle nie zna** (brak serii `up`) — po cutoverze totalnym chelsty-infra nie miałby żadnej liveności i żadnego przejścia offline→online. Dodatkowo docs sygnalizują konflikt IP w komentarzach `prometheus.yml:57` vs `hosts/chelsty-infra/host.yaml:12` — do wyjaśnienia przy ewentualnym dodawaniu scrape. *(rzeczywisty bieżący stan chelsty — do weryfikacji na żywo; ostatni zapis: UNREACHABLE, `docs/infra/inventory-verify-2026-07-02.md:17,151`)* **Wniosek twardy:** cutover NIE może być globalny. Docelowa architektura to **hybryda per-node**: `up{}` dla scrape'owanych (vps, piha, solaria, lustro), freshness eventowa dla chelsty-infra (i każdego przyszłego węzła bez exportera). To zresztą wymusza zachowanie całej ścieżki eventowej liveności w kodzie — czyli naturalnie wychodzi „source per node", nie „wyrwanie starego kodu". --- ## F. REKOMENDACJA CUTOVERU — plan etapowy ### Zasada nadrzędna Cutover = **podmiana źródła klasyfikacji liveności w jednym miejscu** (`observer.py:312-314` + neutralizacja event-driven flipów `:438-446` dla węzłów prom-sourced), przy zachowaniu w całości: rsynca, bogatych eventów, heartbeatu `node_health` (nośnik metryk disk/mem/cpu + `last_seen` dla safety netu paneli), syntetycznych eventów przejść (tor alertowy supervisora) i pól `status`/`liveness`/ `last_seen` w `nodes.json` (kontrakt paneli, bota i onboardingu). **Nic nie jest fizycznie odłączane.** ### Etap 0 — dowód bojowy istniejącego toru Prometheus (bez zmian w repo) - Zweryfikować na żywo: `PROMETHEUS_URL` ustawione w `/opt/homelab/config/brain-watchdog/.env` na piha; poll działa; symulowany NodeDown (stop node_exporter na piha na > 5 m) dociera na Telegram i wysyła recovery. Sesja `docs/sessions/2026-06-30-prometheus-liveness.md:84-91` wprost mówi, że to *nie było jeszcze proven end-to-end*. - Zweryfikować, że observer-kontener (bridged, VPS) dosięga `http://100.95.58.48:9090` (curl z wnętrza kontenera). - **Weryfikacja:** alert + recovery na Telegramie. **Rollback:** n/d (nic nie zmieniamy). ### Etap 1 — PARALLEL-RUN (shadow read w observerze) - Zmiana tylko w `scripts/observer/observer.py` (+ env w compose): opcjonalny `PROMETHEUS_URL`; co cykl (z wewnętrznym cache ~30–60 s, żeby nie odpytywać co 5 s) `GET /api/v1/query?query=up{job="fleet-node"}`; wynik → **nowe, nienaruszające pola** w `nodes.json`: np. `prom_up` (0/1/null), `prom_scrape_age_s`, `prom_liveness` (tier liczony przez ten sam `compute_liveness` z prom-last_seen). - **Żadnej zmiany `status`/`liveness`/`last_seen`.** Rozbieżność (`prom_liveness != liveness` u węzła znanego obu źródłom) → log WARN + (opcjonalnie) event `liveness_source_mismatch` info — widoczny w feedzie `/events`, nieroutowany przez supervisor. - Fail-open: timeout/błąd/brak env → pomiń, stary tor bez zmian. - Testy: jednostkowe na mapowanie odpowiedzi API → tiery (mock HTTP), plus istniejące `tests/test_liveness.py` bez regresji. DoD repo: build + smoke run + pytest. - **Weryfikacja:** ≥ 7 dni obserwacji logów mismatch; sprawdzić znane scenariusze (restart węzła, restart node-agenta przy żywym węźle — spodziewana *znana* rozbieżność semantyk z A.3, restart Prometheusa). - **Rollback:** usunąć `PROMETHEUS_URL` z env observera (restart kontenera). ### Etap 2 — analiza zgodności + decyzja mappingu - Przegląd tygodnia rozbieżności; sklasyfikować każdą (semantyka vs bug). - Ustalić finalny mapping (rekomendacja: `prom-last_seen = timestamp(up)` przez istniejące `compute_liveness` i istniejące TTL-e — najmniej nowej semantyki). - Kryterium wyjścia: zero niewyjaśnionych rozbieżności dla vps/piha/solaria/lustro przez ≥ 7 kolejnych dni. - **Rollback:** n/d (etap analityczny). ### Etap 3 — przełączenie per-node za flagą - Env np. `PROM_LIVENESS_NODES="vps,piha,solaria,lustro"` (pusta = zachowanie dziś). Dla węzłów z listy: - `_prune_stale_world` (`observer.py:312-314`) liczy tier z prom-last_seen zamiast event-last_seen (fallback na eventy, gdy Prometheus nie odpowiada przez > TTL-fresh — fail-open na stary tor, nigdy „brak danych"); - event-driven flip `status="online"` przy `node_health` (`observer.py:446`) dla tych węzłów przestaje nadpisywać prom-status (albo zostaje — i tak autorytatywna klasyfikacja co 5 s go koryguje; do decyzji w implementacji); - `last_seen` w `nodes.json` **nadal z eventów** (`observer.py:436` bez zmian) — panele i safety net (`liveness.py:116-138`) działają bez modyfikacji; - `_emit_node_transition` działa jak dziś — przejścia z nowego źródła emitują te same `node_offline/stale/online`, supervisor alert path bez zmian. - Kolejność włączania: najpierw `solaria,lustro` (najmniejsza szkoda przy pomyłce, brak NodeDown), po tygodniu `vps,piha`. - **Weryfikacja:** panel pokazuje spójne statusy; kontrolowany test — stop node_exporter na solaria → panel stale→offline + `node_stale`/`node_offline` w akcjach/Telegramie; stop node-agenta przy żywym exporterze → status ZOSTAJE online (nowa, pożądana semantyka), a brak metryk disk/mem widoczny osobno. - **Rollback:** wyczyścić `PROM_LIVENESS_NODES` (restart observera) — natychmiastowy powrót do TTL eventowego. ### Etap 4 — (opcjonalnie, osobna decyzja) odchudzenie starej rury Dopiero po długim stabilnym etapie 3. Kandydat: wydłużenie `CHECK_INTERVAL` node-agenta na prom-węzłach (mniej rsynca). **Pułapki, przez które NIE robić tego pochopnie:** (a) `node_health` wozi disk/mem/cpu do `nodes.json` i `disk_pressure` do supervisora (`supervisor.py:315-319`); (b) read-time safety net paneli liczy z `last_seen` — rzadszy heartbeat przy niezmienionych TTL-ach = fałszywe degraded (`liveness.py:16-18` wprost wiąże TTL z interwałem 60 s); (c) onboarding gate 50-verify. Realnie: zostawić 60 s i uznać, że stara rura po prostu przestaje być *źródłem klasyfikacji*, a zostaje nośnikiem metryk i bogatych eventów. ### Etap 5 — domknięcie luk pokrycia (osobne taski, poza cutoverem) - chelsty-infra: zbadać exporter-over-LTE (`prometheus.yml:57-60` + konflikt IP z `hosts/chelsty-infra/host.yaml:12`); do tego czasu zostaje na torze eventowym. - NodeDown dla solaria/lustro: świadomie odroczone do anomaly detection (`docs/backlog.md:390-406`) — nie wciągać do cutoveru. - Watchdog na sam Prometheus (D.2 pkt 5) — mały task przy etapie 3. - saturn / chelsty-ha: świadomie poza monitoringiem — status quo. ### Jeden task czy seria? **Seria.** Minimalnie trzy taski implementacyjne + weryfikacje między nimi: (1) etap 1 shadow-read; (2) etap 3 flaga per-node (po tygodniu etapu 2); (3) watchdog-na-Prometheusa + aktualizacja `docs/observer-runtime.md`. Etap 0 to czynność operatorska (runtime, nie repo). Etap 5 to niezależny backlog. --- ## TL;DR **Stary tor:** node-agent co 60 s emituje m.in. bezwarunkowy heartbeat `node_health` (`node_agent.py:636-640`) i rsyncuje eventy na VPS (`:530-566`); observer (`scripts/observer/observer.py`) z każdego eventu podbija `last_seen` (`:436`), a co 5 s klasyfikuje tier TTL-owo w `_prune_stale_world` (`:312-314`, `liveness.py:88-103`; 180/600 s, remote 900/3600 s), pisze `status/liveness` do `nodes.json` i na przejściach emituje syntetyczne `node_offline/stale/online` (`:199-254`), które supervisor zamienia na alert_only→Telegram (`supervisor.py:742-783`). Panele + bot czytają pola liveności z `nodes.json` (z read-time safety netem z `last_seen`), supervisor-remediacja i executor są od liveności niezależne, brain-watchdog już dziś chodzi po torze Prometheus (`/api/v1/alerts`, NodeDown `up{node=~"vps|piha"}==0 for 5m`). **Cutover = podmiana źródła klasyfikacji w `observer.py:312-314` (+ neutralizacja `:438-446` dla prom-węzłów), hybrydowo per-node.** Fizycznie nic nie odłączamy: rsync, bogate eventy, heartbeat (nośnik disk/mem/cpu i `last_seen`), syntetyczne eventy przejść i pola w `nodes.json` zostają. **Kolejność etapów:** 0) dowód bojowy toru Prometheus→watchdog→Telegram + reachability observer→9090; 1) shadow-read w observerze (nowe pola `prom_*`, log mismatch, fail-open); 2) ≥ 7 dni zgodności + decyzja mappingu (rekomendacja: `timestamp(up)` jako prom-last_seen przez istniejące `compute_liveness`); 3) flaga `PROM_LIVENESS_NODES`, najpierw solaria+lustro, potem vps+piha; rollback = wyczyszczenie flagi; 4) ewentualne odchudzenie — osobna decyzja, domyślnie nie; 5) luki pokrycia (chelsty exporter, watchdog na Prometheus) jako osobne taski. **Seria 3 tasków, nie jeden.** **Ryzyka:** 1. **Prometheus jako SPOF liveności** — `mem_limit 512m`, `oom_score_adj 200` (ubijalny przed control-plane) i nikt go nie pilnuje; wymagany fail-open na stary tor + watchdog na sam Prometheus. 2. **chelsty-infra tylko na starym torze** — globalny cutover zostawiłby go bez liveności; hybryda per-node obowiązkowa. 3. **Różnica semantyk** — event-freshness mierzy node+agent+SSH-łańcuch, `up{}` mierzy exporter; padnięty node-agent przy żywym węźle to *zamierzona* zmiana werdyktu po cutoverze — musi być świadomie zaakceptowana w etapie 2. 4. **Read-time safety net paneli liczy z `last_seen`** — każde majstrowanie przy heartbeacie bez zmiany `liveness.node_health()` = fałszywe degraded; dlatego heartbeat zostaje. 5. **Kontrakty na `nodes.json.status`** — onboarding `50-verify.sh:110-142`, telegram `/nodes`, webui-fallback; pole musi być pisane dalej. 6. **Exportery piha/solaria/lustro poza GitOps** (brak definicji w repo) — reboot węzła może ubić źródło prawdy; do inwentaryzacji przy etapie 1. 7. Syntetyczne eventy przejść to jedyny nośnik node-down→Telegram (poza NodeDown dla vps/piha) — implementacja etapu 3 nie może zgubić `_emit_node_transition`. **Do weryfikacji na żywo** (nie do ustalenia z repo): stan `.env` brain-watchdoga na piha i dowód toru alertowego; reachability `100.95.58.48:9090` z kontenera observera; sposób deployu node_exporterów na piha/solaria/lustro; bieżący stan chelsty-infra (ostatni zapis: UNREACHABLE 2026-07-02); wolumen i stan `/opt/homelab/events` + checkpoint na VPS.