homelab-codex-ws/kb/audits/prometheus-cutover-2026-07-06.md
oskar 9f77a723e8 feat(kb): 5 audytow/reconow -> kb/audits/ (type: audit, as_of)
czujniki-2026-07-30 (node-agent vs stability-agent)
lustro-shipping-2026-07-16 (event=dead prom=up, 1507 mismatchy)
prometheus-cutover-2026-07-06 (recon starego toru livenesci)
piha-slim-2026-07-02 (audyt odchudzania PIHA)
vps-stacki-2026-07-27 (audyt niezarzadzanych stackow na VPS)

ODSTEPSTWO OD RECONU — swiadome. Recon typowal te 5 plikow jako SPLIT
(audit+decision / audit+incident / audit+phase). Rozstrzygniecie 2 wprowadza
typ `audit` z polem as_of i mapuje kazdy z nich na JEDNA sciezke
kb/audits/<obszar>-<data>.md. Audyt jest spojna migawka stanu z konkretna
data — rozbicie go na "ustalenia" i "rekomendacje" rozerwaloby ten kontekst
i wymagaloby redakcji tresci, czego etap 2 zabrania. Zostaja w calosci.

Efekt: 29 SPLIT-ow z reconu realizowane jako 24 (10 service+runbook,
14 wielotypowych), 5 zamienionych na caloscowe dokumenty type: audit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:58:46 +02:00

32 KiB
Raw Blame History

okf type visibility status updated as_of links
0.1 audit private active 2026-07-06 2026-07-06

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_healthlast_seen → TTL → online/stale/offline. To zastępuje Prometheus up{}.
  2. Bogate eventy aplikacyjnecontainers_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>.jsonpł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:436world_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-441node_onlinestatus="online", node_offline"offline".
  • observer.py:443-453node_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-314compute_liveness(last_seen, now, ttls_for(node, roles)). ⟵ switch point #3 — GŁÓWNE miejsce podmiany źródła
  • :315-317UNKNOWN (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-319disk_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

  • /nodescurrent_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.
  • /servicescurrent_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-142twardy 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 ~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 (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ścimem_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.