homelab-codex-ws/kb/audits/prometheus-cutover-2026-07-06.md
oskar 4658089e21 fix(kb): przepiecie wszystkich odwolan wewnetrznych po migracji
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>
2026-08-04 16:58:46 +02:00

528 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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-<node>-<unixts>-<type>-<svc_slug>` (`: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/<node>/<event_id>.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/<node>/…`), 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/<node>/ oskar@100.95.58.48:/opt/homelab/events/<node>/
```
- **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/<node>/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/<node>/` (`: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/<node>/` (`: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, `kb/phases/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 sstale,
N sdead (N z obecnych TTL); brak seriiUNKNOWN (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`, `kb/phases/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, `kb/subsystems/fleet-inventory-verify.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 ~3060 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
(`kb/phases/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 `kb/subsystems/observer.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.