518 lines
32 KiB
Markdown
518 lines
32 KiB
Markdown
|
|
# 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, `docs/backlog.md:390-406`).
|
|||
|
|
Bez Alertmanagera by design (`:3-7`) — delivery = brain-watchdog poll `/api/v1/alerts`.
|
|||
|
|
- node_exporter w repo tylko dla VPS (`services/node_exporter/`, network_mode: host,
|
|||
|
|
`hosts/vps/services.yaml:36-43`). Exportery na piha/solaria/lustro **nie mają
|
|||
|
|
definicji w repo** — istnieją poza GitOps. *(do weryfikacji na żywo: jak są
|
|||
|
|
postawione i czy wstaną po reboocie)*
|
|||
|
|
|
|||
|
|
### D.2 Czego brakuje, żeby observer pytał Prometheus o liveność
|
|||
|
|
|
|||
|
|
1. **Klient HTTP w observerze** — dziś zero (B.4). Potrzebne:
|
|||
|
|
`GET http://100.95.58.48:9090/api/v1/query?query=up{job="fleet-node"}` (stdlib
|
|||
|
|
`urllib`, jak w brain-watchdog). Observer to bridged kontener na VPS bez wspólnej
|
|||
|
|
sieci docker z fleet-prometheus — musi iść przez Tailscale IP hosta, bo port
|
|||
|
|
nie jest na `0.0.0.0`. *(routing bridged→tailscale-IP-hosta do weryfikacji na
|
|||
|
|
żywo; brain-watchdog z pihy działa przez mesh — sesja 2026-06-30)*
|
|||
|
|
2. **Konfiguracja**: env `PROMETHEUS_URL` (opcjonalny → fail-open) w serwisie
|
|||
|
|
`observer` w `services/control-plane/docker-compose.yml:18-35`.
|
|||
|
|
3. **Mapowanie zbiorów węzłów**: Prometheus zna {vps, piha, solaria, lustro};
|
|||
|
|
topology (`inventory/topology.yaml:34-116`) zna 7: + saturn, chelsty-infra,
|
|||
|
|
chelsty-ha. Observer musi wiedzieć, dla których węzłów `up{}` jest autorytatywne,
|
|||
|
|
a dla których zostaje freshness eventowa → **liveność hybrydowa per-node**
|
|||
|
|
(lista w konfigu lub dynamicznie: „węzeł ma serię `up`→Prometheus, nie ma→eventy").
|
|||
|
|
4. **Mapowanie semantyki**: `up` jest binarne (1/0/brak serii), tier `stale` nie ma
|
|||
|
|
bezpośredniego odpowiednika. Propozycja: `up==1`→fresh; `up==0` przez < N s→stale,
|
|||
|
|
≥ N s→dead (N z obecnych TTL); brak serii→UNKNOWN (nie dotykaj — dokładnie
|
|||
|
|
dzisiejsza semantyka `liveness.py:96-97`). Alternatywa odporniejsza na restart
|
|||
|
|
Prometheusa: `time() - timestamp(up)` jako „prom-last_seen" wpuszczone w istniejące
|
|||
|
|
`compute_liveness` — minimalna zmiana, te same TTL-e i te same przejścia.
|
|||
|
|
5. **Watchdog na sam Prometheus**: po cutoverze Prometheus staje się źródłem prawdy,
|
|||
|
|
a nikt go nie pilnuje (brain-watchdog pilnuje mózgu i *konsumuje* alerty, ale nie
|
|||
|
|
alarmuje, gdy Prometheus umrze — poll po prostu cichnie). Potrzebny alert/telegram
|
|||
|
|
na błąd pollingu lub na `up{job="prometheus"}`.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## E. LUKA POKRYCIA
|
|||
|
|
|
|||
|
|
Węzły z `inventory/topology.yaml:34-116` (7): saturn, piha, solaria, vps,
|
|||
|
|
chelsty-infra, chelsty-ha, lustro.
|
|||
|
|
|
|||
|
|
| węzeł | Prometheus scrape | NodeDown alert | node-agent (stary tor) | wniosek cutoverowy |
|
|||
|
|
|---|---|---|---|---|
|
|||
|
|
| vps | TAK (`prometheus.yml:40-42`) | TAK | TAK | pełne pokrycie oboma — kandydat na cutover |
|
|||
|
|
| piha | TAK (`:44-46`) | TAK | TAK | jw. |
|
|||
|
|
| solaria | TAK (`:47-49`) | NIE (`liveness.yml:12-16`) | TAK | cutover statusu OK; alert dalej brak (świadomie, anomaly detection later) |
|
|||
|
|
| lustro | TAK (`:50-52`) | NIE | TAK (bez stability-agenta) | jw. |
|
|||
|
|
| **chelsty-infra** | **NIE** (`:57-60`, exporter DOWN po LTE) | NIE | **TAK** (remote TTL 900/3600, `liveness.py:43-50`) | **tylko stary tor — cutover totalny zostawiłby go bez liveności** |
|
|||
|
|
| chelsty-ha | NIE | NIE | NIE (`hosts/chelsty-ha/services.yaml:6-12`, `monitor: false`) | już dziś bez liveności (pośrednio przez MQTT chelsty-infra) — cutover nic nie zmienia |
|
|||
|
|
| saturn | NIE (`:55`, laptop) | NIE | NIE (brak `hosts/saturn/services.yaml`, `docs/backlog.md:423`) | już dziś bez liveności — cutover nic nie zmienia |
|
|||
|
|
|
|||
|
|
**Chelsty offline ~34 dni — jak traktuje go stara rura:** eventy buforują się lokalnie
|
|||
|
|
(rsync fail = non-fatal, `node_agent.py:560-566`), `last_seen` na VPS zamrożone sprzed
|
|||
|
|
outage'u → `compute_liveness` z remote TTL (dead > 3600 s) klasyfikuje DEAD →
|
|||
|
|
`status="offline"` w `nodes.json`, panel pokazuje error; syntetyczny `node_offline`
|
|||
|
|
poszedł raz przy przejściu (potem cooldown/dedup). Po powrocie LTE: zaległe eventy
|
|||
|
|
dojeżdżają rsynciem, `last_seen` skacze do przodu, tier wraca, `node_online` się
|
|||
|
|
emituje. **Prometheus tego węzła w ogóle nie zna** (brak serii `up`) — po cutoverze
|
|||
|
|
totalnym chelsty-infra nie miałby żadnej liveności i żadnego przejścia offline→online.
|
|||
|
|
Dodatkowo docs sygnalizują konflikt IP w komentarzach `prometheus.yml:57` vs
|
|||
|
|
`hosts/chelsty-infra/host.yaml:12` — do wyjaśnienia przy ewentualnym dodawaniu scrape.
|
|||
|
|
*(rzeczywisty bieżący stan chelsty — do weryfikacji na żywo; ostatni zapis:
|
|||
|
|
UNREACHABLE, `docs/infra/inventory-verify-2026-07-02.md:17,151`)*
|
|||
|
|
|
|||
|
|
**Wniosek twardy:** cutover NIE może być globalny. Docelowa architektura to
|
|||
|
|
**hybryda per-node**: `up{}` dla scrape'owanych (vps, piha, solaria, lustro),
|
|||
|
|
freshness eventowa dla chelsty-infra (i każdego przyszłego węzła bez exportera).
|
|||
|
|
To zresztą wymusza zachowanie całej ścieżki eventowej liveności w kodzie — czyli
|
|||
|
|
naturalnie wychodzi „source per node", nie „wyrwanie starego kodu".
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## F. REKOMENDACJA CUTOVERU — plan etapowy
|
|||
|
|
|
|||
|
|
### Zasada nadrzędna
|
|||
|
|
|
|||
|
|
Cutover = **podmiana źródła klasyfikacji liveności w jednym miejscu**
|
|||
|
|
(`observer.py:312-314` + neutralizacja event-driven flipów `:438-446` dla węzłów
|
|||
|
|
prom-sourced), przy zachowaniu w całości: rsynca, bogatych eventów, heartbeatu
|
|||
|
|
`node_health` (nośnik metryk disk/mem/cpu + `last_seen` dla safety netu paneli),
|
|||
|
|
syntetycznych eventów przejść (tor alertowy supervisora) i pól `status`/`liveness`/
|
|||
|
|
`last_seen` w `nodes.json` (kontrakt paneli, bota i onboardingu). **Nic nie jest
|
|||
|
|
fizycznie odłączane.**
|
|||
|
|
|
|||
|
|
### Etap 0 — dowód bojowy istniejącego toru Prometheus (bez zmian w repo)
|
|||
|
|
|
|||
|
|
- Zweryfikować na żywo: `PROMETHEUS_URL` ustawione w `/opt/homelab/config/brain-watchdog/.env`
|
|||
|
|
na piha; poll działa; symulowany NodeDown (stop node_exporter na piha na > 5 m)
|
|||
|
|
dociera na Telegram i wysyła recovery. Sesja `docs/sessions/2026-06-30-prometheus-liveness.md:84-91`
|
|||
|
|
wprost mówi, że to *nie było jeszcze proven end-to-end*.
|
|||
|
|
- Zweryfikować, że observer-kontener (bridged, VPS) dosięga `http://100.95.58.48:9090`
|
|||
|
|
(curl z wnętrza kontenera).
|
|||
|
|
- **Weryfikacja:** alert + recovery na Telegramie. **Rollback:** n/d (nic nie zmieniamy).
|
|||
|
|
|
|||
|
|
### Etap 1 — PARALLEL-RUN (shadow read w observerze)
|
|||
|
|
|
|||
|
|
- Zmiana tylko w `scripts/observer/observer.py` (+ env w compose): opcjonalny
|
|||
|
|
`PROMETHEUS_URL`; co cykl (z wewnętrznym cache ~30–60 s, żeby nie odpytywać co 5 s)
|
|||
|
|
`GET /api/v1/query?query=up{job="fleet-node"}`; wynik → **nowe, nienaruszające pola**
|
|||
|
|
w `nodes.json`: np. `prom_up` (0/1/null), `prom_scrape_age_s`, `prom_liveness`
|
|||
|
|
(tier liczony przez ten sam `compute_liveness` z prom-last_seen).
|
|||
|
|
- **Żadnej zmiany `status`/`liveness`/`last_seen`.** Rozbieżność
|
|||
|
|
(`prom_liveness != liveness` u węzła znanego obu źródłom) → log WARN +
|
|||
|
|
(opcjonalnie) event `liveness_source_mismatch` info — widoczny w feedzie `/events`,
|
|||
|
|
nieroutowany przez supervisor.
|
|||
|
|
- Fail-open: timeout/błąd/brak env → pomiń, stary tor bez zmian.
|
|||
|
|
- Testy: jednostkowe na mapowanie odpowiedzi API → tiery (mock HTTP), plus
|
|||
|
|
istniejące `tests/test_liveness.py` bez regresji. DoD repo: build + smoke run + pytest.
|
|||
|
|
- **Weryfikacja:** ≥ 7 dni obserwacji logów mismatch; sprawdzić znane scenariusze
|
|||
|
|
(restart węzła, restart node-agenta przy żywym węźle — spodziewana *znana*
|
|||
|
|
rozbieżność semantyk z A.3, restart Prometheusa).
|
|||
|
|
- **Rollback:** usunąć `PROMETHEUS_URL` z env observera (restart kontenera).
|
|||
|
|
|
|||
|
|
### Etap 2 — analiza zgodności + decyzja mappingu
|
|||
|
|
|
|||
|
|
- Przegląd tygodnia rozbieżności; sklasyfikować każdą (semantyka vs bug).
|
|||
|
|
- Ustalić finalny mapping (rekomendacja: `prom-last_seen = timestamp(up)` przez
|
|||
|
|
istniejące `compute_liveness` i istniejące TTL-e — najmniej nowej semantyki).
|
|||
|
|
- Kryterium wyjścia: zero niewyjaśnionych rozbieżności dla vps/piha/solaria/lustro
|
|||
|
|
przez ≥ 7 kolejnych dni.
|
|||
|
|
- **Rollback:** n/d (etap analityczny).
|
|||
|
|
|
|||
|
|
### Etap 3 — przełączenie per-node za flagą
|
|||
|
|
|
|||
|
|
- Env np. `PROM_LIVENESS_NODES="vps,piha,solaria,lustro"` (pusta = zachowanie dziś).
|
|||
|
|
Dla węzłów z listy:
|
|||
|
|
- `_prune_stale_world` (`observer.py:312-314`) liczy tier z prom-last_seen
|
|||
|
|
zamiast event-last_seen (fallback na eventy, gdy Prometheus nie odpowiada
|
|||
|
|
przez > TTL-fresh — fail-open na stary tor, nigdy „brak danych");
|
|||
|
|
- event-driven flip `status="online"` przy `node_health` (`observer.py:446`)
|
|||
|
|
dla tych węzłów przestaje nadpisywać prom-status (albo zostaje — i tak
|
|||
|
|
autorytatywna klasyfikacja co 5 s go koryguje; do decyzji w implementacji);
|
|||
|
|
- `last_seen` w `nodes.json` **nadal z eventów** (`observer.py:436` bez zmian) —
|
|||
|
|
panele i safety net (`liveness.py:116-138`) działają bez modyfikacji;
|
|||
|
|
- `_emit_node_transition` działa jak dziś — przejścia z nowego źródła emitują
|
|||
|
|
te same `node_offline/stale/online`, supervisor alert path bez zmian.
|
|||
|
|
- Kolejność włączania: najpierw `solaria,lustro` (najmniejsza szkoda przy pomyłce,
|
|||
|
|
brak NodeDown), po tygodniu `vps,piha`.
|
|||
|
|
- **Weryfikacja:** panel pokazuje spójne statusy; kontrolowany test — stop
|
|||
|
|
node_exporter na solaria → panel stale→offline + `node_stale`/`node_offline`
|
|||
|
|
w akcjach/Telegramie; stop node-agenta przy żywym exporterze → status ZOSTAJE
|
|||
|
|
online (nowa, pożądana semantyka), a brak metryk disk/mem widoczny osobno.
|
|||
|
|
- **Rollback:** wyczyścić `PROM_LIVENESS_NODES` (restart observera) — natychmiastowy
|
|||
|
|
powrót do TTL eventowego.
|
|||
|
|
|
|||
|
|
### Etap 4 — (opcjonalnie, osobna decyzja) odchudzenie starej rury
|
|||
|
|
|
|||
|
|
Dopiero po długim stabilnym etapie 3. Kandydat: wydłużenie `CHECK_INTERVAL`
|
|||
|
|
node-agenta na prom-węzłach (mniej rsynca). **Pułapki, przez które NIE robić tego
|
|||
|
|
pochopnie:** (a) `node_health` wozi disk/mem/cpu do `nodes.json` i `disk_pressure`
|
|||
|
|
do supervisora (`supervisor.py:315-319`); (b) read-time safety net paneli liczy
|
|||
|
|
z `last_seen` — rzadszy heartbeat przy niezmienionych TTL-ach = fałszywe degraded
|
|||
|
|
(`liveness.py:16-18` wprost wiąże TTL z interwałem 60 s); (c) onboarding gate
|
|||
|
|
50-verify. Realnie: zostawić 60 s i uznać, że stara rura po prostu przestaje być
|
|||
|
|
*źródłem klasyfikacji*, a zostaje nośnikiem metryk i bogatych eventów.
|
|||
|
|
|
|||
|
|
### Etap 5 — domknięcie luk pokrycia (osobne taski, poza cutoverem)
|
|||
|
|
|
|||
|
|
- chelsty-infra: zbadać exporter-over-LTE (`prometheus.yml:57-60` + konflikt IP
|
|||
|
|
z `hosts/chelsty-infra/host.yaml:12`); do tego czasu zostaje na torze eventowym.
|
|||
|
|
- NodeDown dla solaria/lustro: świadomie odroczone do anomaly detection
|
|||
|
|
(`docs/backlog.md:390-406`) — nie wciągać do cutoveru.
|
|||
|
|
- Watchdog na sam Prometheus (D.2 pkt 5) — mały task przy etapie 3.
|
|||
|
|
- saturn / chelsty-ha: świadomie poza monitoringiem — status quo.
|
|||
|
|
|
|||
|
|
### Jeden task czy seria?
|
|||
|
|
|
|||
|
|
**Seria.** Minimalnie trzy taski implementacyjne + weryfikacje między nimi:
|
|||
|
|
(1) etap 1 shadow-read; (2) etap 3 flaga per-node (po tygodniu etapu 2);
|
|||
|
|
(3) watchdog-na-Prometheusa + aktualizacja `docs/observer-runtime.md`.
|
|||
|
|
Etap 0 to czynność operatorska (runtime, nie repo). Etap 5 to niezależny backlog.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## TL;DR
|
|||
|
|
|
|||
|
|
**Stary tor:** node-agent co 60 s emituje m.in. bezwarunkowy heartbeat `node_health`
|
|||
|
|
(`node_agent.py:636-640`) i rsyncuje eventy na VPS (`:530-566`); observer
|
|||
|
|
(`scripts/observer/observer.py`) z każdego eventu podbija `last_seen` (`:436`),
|
|||
|
|
a co 5 s klasyfikuje tier TTL-owo w `_prune_stale_world` (`:312-314`,
|
|||
|
|
`liveness.py:88-103`; 180/600 s, remote 900/3600 s), pisze `status/liveness` do
|
|||
|
|
`nodes.json` i na przejściach emituje syntetyczne `node_offline/stale/online`
|
|||
|
|
(`:199-254`), które supervisor zamienia na alert_only→Telegram (`supervisor.py:742-783`).
|
|||
|
|
Panele + bot czytają pola liveności z `nodes.json` (z read-time safety netem
|
|||
|
|
z `last_seen`), supervisor-remediacja i executor są od liveności niezależne,
|
|||
|
|
brain-watchdog już dziś chodzi po torze Prometheus (`/api/v1/alerts`, NodeDown
|
|||
|
|
`up{node=~"vps|piha"}==0 for 5m`).
|
|||
|
|
|
|||
|
|
**Cutover = podmiana źródła klasyfikacji w `observer.py:312-314` (+ neutralizacja
|
|||
|
|
`:438-446` dla prom-węzłów), hybrydowo per-node.** Fizycznie nic nie odłączamy:
|
|||
|
|
rsync, bogate eventy, heartbeat (nośnik disk/mem/cpu i `last_seen`), syntetyczne
|
|||
|
|
eventy przejść i pola w `nodes.json` zostają.
|
|||
|
|
|
|||
|
|
**Kolejność etapów:** 0) dowód bojowy toru Prometheus→watchdog→Telegram +
|
|||
|
|
reachability observer→9090; 1) shadow-read w observerze (nowe pola `prom_*`,
|
|||
|
|
log mismatch, fail-open); 2) ≥ 7 dni zgodności + decyzja mappingu
|
|||
|
|
(rekomendacja: `timestamp(up)` jako prom-last_seen przez istniejące
|
|||
|
|
`compute_liveness`); 3) flaga `PROM_LIVENESS_NODES`, najpierw solaria+lustro,
|
|||
|
|
potem vps+piha; rollback = wyczyszczenie flagi; 4) ewentualne odchudzenie —
|
|||
|
|
osobna decyzja, domyślnie nie; 5) luki pokrycia (chelsty exporter, watchdog na
|
|||
|
|
Prometheus) jako osobne taski. **Seria 3 tasków, nie jeden.**
|
|||
|
|
|
|||
|
|
**Ryzyka:**
|
|||
|
|
1. **Prometheus jako SPOF liveności** — `mem_limit 512m`, `oom_score_adj 200`
|
|||
|
|
(ubijalny przed control-plane) i nikt go nie pilnuje; wymagany fail-open na
|
|||
|
|
stary tor + watchdog na sam Prometheus.
|
|||
|
|
2. **chelsty-infra tylko na starym torze** — globalny cutover zostawiłby go bez
|
|||
|
|
liveności; hybryda per-node obowiązkowa.
|
|||
|
|
3. **Różnica semantyk** — event-freshness mierzy node+agent+SSH-łańcuch, `up{}`
|
|||
|
|
mierzy exporter; padnięty node-agent przy żywym węźle to *zamierzona* zmiana
|
|||
|
|
werdyktu po cutoverze — musi być świadomie zaakceptowana w etapie 2.
|
|||
|
|
4. **Read-time safety net paneli liczy z `last_seen`** — każde majstrowanie przy
|
|||
|
|
heartbeacie bez zmiany `liveness.node_health()` = fałszywe degraded; dlatego
|
|||
|
|
heartbeat zostaje.
|
|||
|
|
5. **Kontrakty na `nodes.json.status`** — onboarding `50-verify.sh:110-142`,
|
|||
|
|
telegram `/nodes`, webui-fallback; pole musi być pisane dalej.
|
|||
|
|
6. **Exportery piha/solaria/lustro poza GitOps** (brak definicji w repo) — reboot
|
|||
|
|
węzła może ubić źródło prawdy; do inwentaryzacji przy etapie 1.
|
|||
|
|
7. Syntetyczne eventy przejść to jedyny nośnik node-down→Telegram (poza NodeDown
|
|||
|
|
dla vps/piha) — implementacja etapu 3 nie może zgubić `_emit_node_transition`.
|
|||
|
|
|
|||
|
|
**Do weryfikacji na żywo** (nie do ustalenia z repo): stan `.env` brain-watchdoga
|
|||
|
|
na piha i dowód toru alertowego; reachability `100.95.58.48:9090` z kontenera
|
|||
|
|
observera; sposób deployu node_exporterów na piha/solaria/lustro; bieżący stan
|
|||
|
|
chelsty-infra (ostatni zapis: UNREACHABLE 2026-07-02); wolumen i stan
|
|||
|
|
`/opt/homelab/events` + checkpoint na VPS.
|