From 171ee2e9e6ff58048b2cc215cc003de6b63d9b2a Mon Sep 17 00:00:00 2001 From: vitya Date: Sun, 23 Aug 2026 19:38:08 +0300 Subject: [PATCH] =?UTF-8?q?feat(mappa-ntfy-monitor):=20deploy=20health=20m?= =?UTF-8?q?onitor=20mappa=20=E2=86=92=20ntfy=20(#1013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /root/mappa-ntfy-monitor/monitor.py (python3 stdlib): GET /health (200 + ok:true + service:mappa), алерт после 3 подряд фейлов раз за эпизод + recovery - cron */5 в /etc/cron.d/mappa-ntfy-monitor, env 600 (NTFY_* из pass), топик mappa-alerts - тесты: dry-run OK / негатив 3× → DOWN-алерт / recovery; стек 26 не тронут - ранбук §Мониторинг живости; источник скрипта в scripts/ --- .tasks/NEXT_SESSION.md | 33 ++-- .../2026-08-23-01013-mappa-ntfy-monitor.md | 11 ++ .wiki/concepts/mappa-vds-deploy-runbook.md | 11 ++ scripts/mappa-ntfy-monitor/monitor.py | 170 ++++++++++++++++++ 4 files changed, 203 insertions(+), 22 deletions(-) rename .tasks/{ => done}/2026-08-23-01013-mappa-ntfy-monitor.md (56%) create mode 100644 scripts/mappa-ntfy-monitor/monitor.py diff --git a/.tasks/NEXT_SESSION.md b/.tasks/NEXT_SESSION.md index 063f077..a22c838 100644 --- a/.tasks/NEXT_SESSION.md +++ b/.tasks/NEXT_SESSION.md @@ -1,43 +1,32 @@ --- -_last_updated_: 2026-08-23T15:43:31Z -session_id: mappa-prod-deploy-2026-08-23 +_last_updated_: 2026-08-23T19:40:00Z +session_id: mappa-ntfy-monitor-2026-08-23 --- # Next session handoff -## Что сделано в этой сессии (закрыто) - -### 🟢 #1012 mappa-prod-deploy — инфра-деплой выполнен, передан mappa -Письмо mappa 15:10Z → взял таску #1012 (ready, notify mappa). Разделение труда подтверждено юзером: **деплой = админ, прод-импорт/verify/read-only/фолбэк/ntfy/MCP = mappa**. Сделано (инфра): - -1. **БД**: отдельная `mappa` (role + db, owner mappa) на общем `postgres:16` VDS. Креды → **`pass show mappa/full-env`** (в т.ч. `DATABASE_URL` для docker-сети: `postgres://mappa:…@postgres:5432/mappa`). -2. **Образ**: `registry.kzntsv.site/mappa:0.1.0`. Dockerfile добавлен в репо mappa (commit `56366eb`). ⚠️ Грабли: tsc не копирует `.sql` → `schema.sql` в dist обязателен (COPY в Dockerfile), иначе migrate() падает «schema.sql not found». Образ пересобирался 2×. -3. **Compose**: source-of-truth `host-stacks/vds-kzntsv/mappa.compose.yml` (commit `8f1b13f3`): mem_limit 512m, restart unless-stopped, сети shared-dbs+proxy, healthcheck wget /health, traefik label → `https://mappa.vds.kzntsv.site`. -4. **Deploy**: Portainer stack 26 `mappa` (standalone string-mode, endpoint 1; скрипт `.tmp/mappa-portainer-deploy.py` + restart). НЕ ad-hoc compose up (VDS ops rule). -5. **Verify**: контейнер Up (healthy), `/health` → `{"ok":true,"service":"mappa"}`, `/admin/status` + `/graph/stats` 200, миграции легли (6 таблиц owner=mappa, БД пустая — entities=0). -6. **Пинг mappa**: письмо `mappa/.agents/inbox/2026-08-23T18-45-00Z-admin-mappa-deployed.md` — доставлено (монитор mappa утащил в `.read/` 18:34). #1012 закрыта (commit `5870b75b`). +## Recent commits +- mappa-ntfy-monitor: монитор health mappa → ntfy задеплоен, #1013 закрыта (2026-08-23) +- server close 56c1cb5: #1013 mappa-ntfy-monitor → 🟢 (note с отчётом в блоке) ## Открытые треки | Трек | Статус | Entry-point | |---|---|---| -| ~~#1012 mappa-prod-deploy~~ | 🟢 закрыто | — | -| mappa #985 mappa-deploy | 🔵 blocked-by #1012 → ждёт 🟢 (импорт+verify+read-only+ntfy+MCP за mappa) | письмо-пинг в mappa inbox доставлено; следить за их 🟢 | | #803 llm-web-proxy | 🔴 active | next action в STATUS.md (executors/translators из omniroute) | | MCP-кэш (meta_status) | 🟡 | synced 2026-08-22 22:19 | | legacy agendaJobs (60 «posting»-сирот) | 🟡 | чистка отдельно | -| fbsPostings.js:73 TypeError | 🟡 | флагнул books | | meeting-room merge-conflict (UU) | 🟡 | разрешить → миграция 2 тасок | +| fbsPostings.js:73 TypeError | 🟡 | флагнул books | ## Спроси user'а -- (нет на этом раунде — деплой закрыт по канону, письмо mappa отправлено) +- (нет на этом раунде — #1013 закрыта по канону, письмо mappa отправлено) ## Не делать (guards) -- Редеплой mappa — только через Portainer (`POST /api/stacks/26/{stop,start}`; JWT как в миграц. скрипте). `docker compose up` по SSH — anti-pattern. -- Новый тег образа mappa → `docker pull` на VDS ДО restart стека (ловил залипание на старом digest). -- Не дублировать прод-импорт/ntfy/read-only — это скоуп mappa (#985), не админа. +- Редеплой mappa — только через Portainer (стек 26; `docker pull` ДО restart). `docker compose up` по SSH — anti-pattern. +- Не трогать mappa-скоуп: read-only файлов + фолбэк + поллер на сервисных — шаг ФЛИПА (решение 15, ~2 недели), решает mappa. +- Монитор mappa наблюдает — редеплой/рестарт стека отдельной задачей, не вручную. - `noreply@snolla.com` — мёртвый ящик, не возвращать в конфиги. - Не чистить legacy agendaJobs без отдельного запроса. ## Memory updates за сессию -- Прод-инфра mappa: БД на общем postgres:16 (база+role mappa, креды в pass), стек Portainer 26, endpoint `mappa.vds.kzntsv.site`, образ `registry.kzntsv.site/mappa:0.1.0`, compose в `host-stacks/vds-kzntsv/mappa.compose.yml`. Всё зафиксировано в closing-note #1012. -- Кандидат в shared-wiki: паттерн «schema.sql не попадает в dist при tsc-сборке — копировать отдельно в Dockerfile» + «Portainer stack не подхватывает новый digest без docker pull». ⚠️ Юзер ОТКАЗАЛ в shared-ingest ранбука mappa (2026-08-23 «нет пока») — ранбук только в локальной .admin-вики (concepts/mappa-vds-deploy-runbook.md, commit e3de6391). +- Монитор mappa задеплоен: `/root/mappa-ntfy-monitor/monitor.py` + cron `*/5` (`/etc/cron.d/mappa-ntfy-monitor`), топик ntfy `mappa-alerts` (vitya admin-role → rw без правки ACL), env `/root/.mappa-ntfy-monitor.env` (600, из pass vds-kzntsv/full-env). Доки: `.wiki/concepts/mappa-vds-deploy-runbook.md` §Мониторинг живости. diff --git a/.tasks/2026-08-23-01013-mappa-ntfy-monitor.md b/.tasks/done/2026-08-23-01013-mappa-ntfy-monitor.md similarity index 56% rename from .tasks/2026-08-23-01013-mappa-ntfy-monitor.md rename to .tasks/done/2026-08-23-01013-mappa-ntfy-monitor.md index 0d71817..1c9bd9d 100644 --- a/.tasks/2026-08-23-01013-mappa-ntfy-monitor.md +++ b/.tasks/done/2026-08-23-01013-mappa-ntfy-monitor.md @@ -25,9 +25,20 @@ ## Key files ## Decisions log +- 2026-08-23: Монитор на VDS (как snolla-smtp-monitor: python3 stdlib, /root/, cron /etc/cron.d/, env 600) — проверка с самого VDS через публичный URL (реальный путь пользователя через traefik). +- 2026-08-23: Алерт после 3 подряд фейлов (~15 мин при */5), раз за эпизод (state-файл, dedupe) — не шуметь на единичном сбое; recovery-сообщение при возврате. +- 2026-08-23: Строгая проверка health: HTTP 200 + ok==true + service=="mappa" (guard от чужого ответа на URL). ## Open questions ## Completed steps +- [x] Монитор задеплоен: /root/mappa-ntfy-monitor/monitor.py (sha совпадает с источником .admin/scripts/mappa-ntfy-monitor/monitor.py) +- [x] Cron */5 в /etc/cron.d/mappa-ntfy-monitor (root, лог /var/log/mappa-ntfy-monitor.log) +- [x] Env /root/.mappa-ntfy-monitor.env (600, из pass vds-kzntsv/full-env), топик mappa-alerts +- [x] Dry-run success: OK health, exit 0 +- [x] Негатив-тест: 3 фейла → DOWN-алерт (priority high, ntfy HTTP 200, проверено в топике); 1-2 фейла — без алерта +- [x] Recovery: RECOVERED-сообщение в топике, state сброшен +- [x] Стек 26 не тронут (только мониторинг) ## Notes +- Деплой: 2026-08-23. Топик mappa-alerts подтверждён в ntfy (vitya admin-role → rw без правки ACL). diff --git a/.wiki/concepts/mappa-vds-deploy-runbook.md b/.wiki/concepts/mappa-vds-deploy-runbook.md index a4ffdd9..d47c7d3 100644 --- a/.wiki/concepts/mappa-vds-deploy-runbook.md +++ b/.wiki/concepts/mappa-vds-deploy-runbook.md @@ -73,6 +73,17 @@ ssh vds.kzntsv.site 'docker ps --filter name=mappa --format "{{.Status}}"' entities/edges/leases/logs, owner=mappa) — `docker logs mappa` + `\dt` в БД. 5. **Пинг mappa**: письмо в `mappa/.agents/inbox/` (event: mappa-deployed) — импорт и остальное за ними. +## Мониторинг живости (монитор → ntfy) + +Остаток #985 (решение 15/18: алерт при падении сервиса) — реализован #1013 (2026-08-23). + +- **Скрипт:** `/root/mappa-ntfy-monitor/monitor.py` (python3 stdlib; источник: `admin/scripts/mappa-ntfy-monitor/monitor.py`). +- **Проверка:** GET `https://mappa.vds.kzntsv.site/health` — HTTP 200 + JSON `{"ok":true,"service":"mappa"}` (таймаут 15s). +- **Алерт:** ntfy топик `mappa-alerts` (basic-auth vitya, admin-role → rw все топики), после 3 подряд фейлов (~15 мин), раз за эпизод (dedupe); recovery-сообщение при возврате. +- **Крон:** `/etc/cron.d/mappa-ntfy-monitor` — `*/5 * * * *` (root, лог `/var/log/mappa-ntfy-monitor.log`). +- **Конфиг:** `/root/.mappa-ntfy-monitor.env` (600; NTFY_* из `pass vds-kzntsv/full-env`). State: `/root/.mappa-ntfy-monitor.state`. +- ⚠️ Монитор НЕ трогает стек 26 — только наблюдение; редеплой/рестарт — отдельной задачей. + ## Gotchas (из первых деплоев) 1. **schema.sql в dist** — tsc компилирует только .ts; `migrate()` ищет `.sql` рядом с diff --git a/scripts/mappa-ntfy-monitor/monitor.py b/scripts/mappa-ntfy-monitor/monitor.py new file mode 100644 index 0000000..dc48592 --- /dev/null +++ b/scripts/mappa-ntfy-monitor/monitor.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""mappa-ntfy-monitor — health check of the mappa service (lives on vds-kzntsv). + +Task #1013 (mappa-ntfy-monitor, remainder of mappa #985): alert to ntfy when the +mappa service on VDS goes down. Monitor-only — never touches the mappa stack +(Portainer 26); redeploy/restart is a separate task (infra on VDS via admin). + +Check (cron every 5 min via /etc/cron.d/mappa-ntfy-monitor): + GET HEALTH_URL (https://mappa.vds.kzntsv.site/health), expect HTTP 200 and + JSON {"ok": true, "service": "mappa"}. Any of: network error / timeout / + non-200 / bad JSON / ok != true / service != mappa counts as a failure. + +Alerting policy (no noise on a single blip): + * consecutive-failure counter persisted in STATE_PATH (JSON); + * after FAIL_THRESHOLD consecutive failures (default 3 = ~15 min) -> one ntfy + alert (Priority high) per outage episode (deduped via the `alerted` flag); + * on recovery -> one ntfy info message, counter reset. + +Config: /root/.mappa-ntfy-monitor.env (chmod 600; built from `pass vds-kzntsv/full-env`). +Keys: HEALTH_URL NTFY_URL NTFY_USER NTFY_PASS NTFY_TOPIC FAIL_THRESHOLD STATE_PATH LOG_PATH +""" +import base64 +import datetime +import json +import os +import sys +import urllib.error +import urllib.request + +DEFAULT_ENV_PATH = "/root/.mappa-ntfy-monitor.env" +DEFAULT_LOG_PATH = "/var/log/mappa-ntfy-monitor.log" +DEFAULT_STATE_PATH = "/root/.mappa-ntfy-monitor.state" +DEFAULT_HEALTH_URL = "https://mappa.vds.kzntsv.site/health" +DEFAULT_NTFY_URL = "https://ntfy.vds.kzntsv.site" +DEFAULT_NTFY_TOPIC = "mappa-alerts" +DEFAULT_FAIL_THRESHOLD = 3 +HEALTH_TIMEOUT = 15 +NTFY_TIMEOUT = 15 + + +def load_env(path): + cfg = {} + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + k, _, v = line.partition("=") + cfg[k.strip()] = v.strip() + return cfg + + +def log(cfg, msg): + ts = datetime.datetime.now().astimezone().strftime("%Y-%m-%dT%H:%M:%S%z") + line = f"{ts} {msg}" + print(line, flush=True) + try: + with open(cfg["LOG_PATH"], "a", encoding="utf-8") as f: + f.write(line + "\n") + except Exception as e: + print(f"log write failed: {e}", flush=True) + + +def load_state(path): + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + return {"fail_count": int(data.get("fail_count", 0)), + "alerted": bool(data.get("alerted", False))} + except Exception: + return {"fail_count": 0, "alerted": False} + + +def save_state(path, state): + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(state, f) + os.replace(tmp, path) + + +def health_ok(cfg): + """Return (ok: bool, detail: str).""" + req = urllib.request.Request(cfg["HEALTH_URL"], method="GET") + try: + with urllib.request.urlopen(req, timeout=HEALTH_TIMEOUT) as r: + status = r.status + raw = r.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + return False, f"HTTP {e.code}" + except Exception as e: + return False, f"network: {e}" + if status != 200: + return False, f"HTTP {status}" + try: + data = json.loads(raw) + except Exception as e: + return False, f"bad json: {raw[:80]!r}" + if data.get("ok") is not True: + return False, f"ok != true: {raw[:80]!r}" + if data.get("service") != "mappa": + return False, f"service != mappa: {raw[:80]!r}" + return True, raw + + +def ntfy_publish(cfg, title, body, priority): + try: + url = f"{cfg['NTFY_URL']}/{cfg['NTFY_TOPIC']}" + req = urllib.request.Request(url, data=body.encode(), method="POST") + token = base64.b64encode(f"{cfg['NTFY_USER']}:{cfg['NTFY_PASS']}".encode()).decode() + req.add_header("Authorization", "Basic " + token) + # urllib encodes header VALUES as latin-1 -> keep title ASCII (emoji goes in body) + req.add_header("Title", title.encode("ascii", "replace").decode("ascii")) + req.add_header("Priority", priority) + with urllib.request.urlopen(req, timeout=NTFY_TIMEOUT) as resp: + log(cfg, f"ntfy publish OK (priority={priority}): HTTP {resp.status}") + return True + except Exception as e: + log(cfg, f"ntfy publish FAILED: {e}") + return False + + +def main(): + env_path = os.environ.get("MAPPA_MONITOR_ENV", DEFAULT_ENV_PATH) + cfg = load_env(env_path) + cfg.setdefault("HEALTH_URL", DEFAULT_HEALTH_URL) + cfg.setdefault("NTFY_URL", DEFAULT_NTFY_URL) + cfg.setdefault("NTFY_TOPIC", DEFAULT_NTFY_TOPIC) + cfg.setdefault("FAIL_THRESHOLD", str(DEFAULT_FAIL_THRESHOLD)) + cfg.setdefault("LOG_PATH", DEFAULT_LOG_PATH) + cfg.setdefault("STATE_PATH", DEFAULT_STATE_PATH) + try: + threshold = int(cfg["FAIL_THRESHOLD"]) + except ValueError: + threshold = DEFAULT_FAIL_THRESHOLD + + ok, detail = health_ok(cfg) + state = load_state(cfg["STATE_PATH"]) + + if ok: + if state["alerted"]: + prior = state["fail_count"] + state = {"fail_count": 0, "alerted": False} + save_state(cfg["STATE_PATH"], state) + ntfy_publish(cfg, "mappa monitor: RECOVERED", + f"mappa health OK again: {detail}", "default") + log(cfg, f"RECOVERED (was down after {prior} consecutive failures)") + else: + state["fail_count"] = 0 + save_state(cfg["STATE_PATH"], state) + log(cfg, f"OK health: {detail}") + return 0 + + state["fail_count"] += 1 + fails = state["fail_count"] + if fails >= threshold and not state["alerted"]: + state["alerted"] = True + save_state(cfg["STATE_PATH"], state) + body = (f"mappa DOWN ({fails} consecutive failures)\n" + f"url={cfg['HEALTH_URL']}\nreason={detail}\n" + f"ts={datetime.datetime.now().astimezone().isoformat()}") + ntfy_publish(cfg, "mappa monitor: DOWN", body, "high") + log(cfg, f"ALERT: mappa down after {fails} consecutive failures ({detail})") + return 1 + save_state(cfg["STATE_PATH"], state) + log(cfg, f"FAIL health ({fails}/{threshold}): {detail}") + return 1 + + +if __name__ == "__main__": + sys.exit(main())