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>
32 KiB
| 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):
- Liveność węzła — heartbeat
node_health→last_seen→ TTL →online/stale/offline. To zastępuje Prometheusup{}. - Bogate eventy aplikacyjne —
containers_not_running,healthcheck_failed,disk_pressure,service_unhealthy… Prometheus tego NIE wozi. Muszą zostać. - 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_PATHdefault/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-partitionedevents/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 globEVENTS_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 → kwarantannastate/observer_failed_events/<node>/(:100-118,:638). - Pętla co 5 s, hardcoded (
:644-648). Heartbeatstate/observer.heartbeatco cykl (:586-590) — na nim wisi compose healthcheck (services/control-plane/docker-compose.yml:30-35). - Uruchomienie: kontener
observerna 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"] = timestampdla KAŻDEGO eventu z węzła, bezwarunkowo. Jedyne miejsce zapisulast_seenz ingestu. ⟵ switch point #1observer.py:438-441—node_online→status="online",node_offline→"offline".observer.py:443-453—node_health:status="online"(:446) + metrykidisk_usage_pct/mem_usage_pct/cpu_usage_pct(:447-451) + kasowaniedisk_pressuregdy 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(braklast_seen) → nie dotykaj statusu.:319-322— zapisnode_info["liveness"]inode_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 wttls_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): wieknow - 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ą wlast_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}.jsonw_load_actual_state(:197-248); pusty/ucięty plik → cały cykl reconcile pomijany (:282-283). - Z
nodes.jsonużywa TYLKOdisk_pressure(reconcile:315-319→disk_cleanuprecommendation;NO_DISK_CLEANUP_NODES= chelsty-*,:44). Żadna decyzja nie czytastatus/liveness/last_seen. - Pętla driftu/remediacji (
:288-308) nie gate'uje na liveność węzła — porównuje desired vsservices.jsoni generuje redeploy/container_restart niezależnie od tego, czy węzeł żyje. (Jedyny hamulec:monitor: falsewservices.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) → akcjaalert_onlyz dedupem poaction_idi 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.jsonbez zmian; łamie się dopiero, gdy przestaną powstawać syntetyczne eventy przejść (cisza zamiast alertu node-down).disk_pressurei 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): czytanodes.json, per węzeł liczyhealthprzezliveness.node_health(_node_health:62-70) — read-time safety net: worse(persistedstatus, freshness zlast_seen) (liveness.py:116-138). Chroni przed zamrożonym observerem (frozennodes.json) — freshness dalej się starzeje./services→current_services(:102-147): czytaservices.jsoninodes.json, liczycompute_livenessper 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,stalegdylast_update> 60 s),/events(:225-256, czyta wprostEVENTS_DIR, nie world).- Frontend
index.html: badgenode.health+last_seen(:539,545), karty topologii kolorowane pohealth(:626-641). - Wniosek cutoverowy: panel jest NAJWIĘKSZYM konsumentem pól liveności.
Bez
status/last_seenwnodes.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 zlast_seen, więc dopóki heartbeat eventowy płynie,last_seenjest świeży i nie kłóci się z prom-statusem; gdyby heartbeat kiedyś wyłączyć,node_health()wliveness.pymusi 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 zlast_updatevs 600 s; wykrywa martwy mózg (observer/world_state). - Tor 2:
check_prometheus_alerts()(:78-105) — GET{PROMETHEUS_URL}/api/v1/alerts, filtrujestate=="firing", kluczalertname:node;handle_prometheus_alerts(:108-138) → Telegram raz na firing + recovery. Tylko/api/v1/alerts, nie/api/v1/query. PROMETHEUS_URLopcjonalne (env.example:8-11, przykładhttp://100.95.58.48:9090).
C.6 Pozostali
- Executor (
executor.py): zero zależności od world — czyta tylkoactions/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 nanodes.jsonstatus∈ {online, offline} (krok 4/4). Musi przeżyć cutover (przeżyje, jeśli polestatusdalej pisane). scripts/deploy/verify-agent-fleet.sh:23-59— liveness z Redisa (tor stability-agenta)- curl
/summary,/nodes.
- curl
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 labelfleet: homelab-codex(:11-12). Jobfleet-node(:36-52) — po jednej labelcenode: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ść
- Klient HTTP w observerze — dziś zero (B.4). Potrzebne:
GET http://100.95.58.48:9090/api/v1/query?query=up{job="fleet-node"}(stdliburllib, 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 na0.0.0.0. (routing bridged→tailscale-IP-hosta do weryfikacji na żywo; brain-watchdog z pihy działa przez mesh — sesja 2026-06-30) - Konfiguracja: env
PROMETHEUS_URL(opcjonalny → fail-open) w serwisieobserverwservices/control-plane/docker-compose.yml:18-35. - 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łówup{}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"). - Mapowanie semantyki:
upjest binarne (1/0/brak serii), tierstalenie ma bezpośredniego odpowiednika. Propozycja:up==1→fresh;up==0przez < N s→stale, ≥ N s→dead (N z obecnych TTL); brak serii→UNKNOWN (nie dotykaj — dokładnie dzisiejsza semantykaliveness.py:96-97). Alternatywa odporniejsza na restart Prometheusa:time() - timestamp(up)jako „prom-last_seen" wpuszczone w istniejącecompute_liveness— minimalna zmiana, te same TTL-e i te same przejścia. - 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_URLustawione w/opt/homelab/config/brain-watchdog/.envna piha; poll działa; symulowany NodeDown (stop node_exporter na piha na > 5 m) dociera na Telegram i wysyła recovery. Sesjadocs/sessions/2026-06-30-prometheus-liveness.md:84-91wprost 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): opcjonalnyPROMETHEUS_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 wnodes.json: np.prom_up(0/1/null),prom_scrape_age_s,prom_liveness(tier liczony przez ten samcompute_livenessz prom-last_seen). - Żadnej zmiany
status/liveness/last_seen. Rozbieżność (prom_liveness != livenessu węzła znanego obu źródłom) → log WARN + (opcjonalnie) eventliveness_source_mismatchinfo — 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.pybez 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_URLz 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ącecompute_livenessi 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"przynode_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_seenwnodes.jsonnadal z eventów (observer.py:436bez zmian) — panele i safety net (liveness.py:116-138) działają bez modyfikacji;_emit_node_transitiondziała jak dziś — przejścia z nowego źródła emitują te samenode_offline/stale/online, supervisor alert path bez zmian.
- Kolejność włączania: najpierw
solaria,lustro(najmniejsza szkoda przy pomyłce, brak NodeDown), po tygodniuvps,piha. - Weryfikacja: panel pokazuje spójne statusy; kontrolowany test — stop
node_exporter na solaria → panel stale→offline +
node_stale/node_offlinew 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 zhosts/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:
- 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. - chelsty-infra tylko na starym torze — globalny cutover zostawiłby go bez liveności; hybryda per-node obowiązkowa.
- 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. - Read-time safety net paneli liczy z
last_seen— każde majstrowanie przy heartbeacie bez zmianyliveness.node_health()= fałszywe degraded; dlatego heartbeat zostaje. - Kontrakty na
nodes.json.status— onboarding50-verify.sh:110-142, telegram/nodes, webui-fallback; pole musi być pisane dalej. - 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.
- 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.