feat(mappa-ntfy-monitor): deploy health monitor mappa → ntfy (#1013)
- /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/
This commit is contained in:
@@ -1,43 +1,32 @@
|
|||||||
---
|
---
|
||||||
_last_updated_: 2026-08-23T15:43:31Z
|
_last_updated_: 2026-08-23T19:40:00Z
|
||||||
session_id: mappa-prod-deploy-2026-08-23
|
session_id: mappa-ntfy-monitor-2026-08-23
|
||||||
---
|
---
|
||||||
|
|
||||||
# Next session handoff
|
# Next session handoff
|
||||||
|
|
||||||
## Что сделано в этой сессии (закрыто)
|
## Recent commits
|
||||||
|
- mappa-ntfy-monitor: монитор health mappa → ntfy задеплоен, #1013 закрыта (2026-08-23)
|
||||||
### 🟢 #1012 mappa-prod-deploy — инфра-деплой выполнен, передан mappa
|
- server close 56c1cb5: #1013 mappa-ntfy-monitor → 🟢 (note с отчётом в блоке)
|
||||||
Письмо 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`).
|
|
||||||
|
|
||||||
## Открытые треки
|
## Открытые треки
|
||||||
| Трек | Статус | Entry-point |
|
| Трек | Статус | 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) |
|
| #803 llm-web-proxy | 🔴 active | next action в STATUS.md (executors/translators из omniroute) |
|
||||||
| MCP-кэш (meta_status) | 🟡 | synced 2026-08-22 22:19 |
|
| MCP-кэш (meta_status) | 🟡 | synced 2026-08-22 22:19 |
|
||||||
| legacy agendaJobs (60 «posting»-сирот) | 🟡 | чистка отдельно |
|
| legacy agendaJobs (60 «posting»-сирот) | 🟡 | чистка отдельно |
|
||||||
| fbsPostings.js:73 TypeError | 🟡 | флагнул books |
|
|
||||||
| meeting-room merge-conflict (UU) | 🟡 | разрешить → миграция 2 тасок |
|
| meeting-room merge-conflict (UU) | 🟡 | разрешить → миграция 2 тасок |
|
||||||
|
| fbsPostings.js:73 TypeError | 🟡 | флагнул books |
|
||||||
|
|
||||||
## Спроси user'а
|
## Спроси user'а
|
||||||
- (нет на этом раунде — деплой закрыт по канону, письмо mappa отправлено)
|
- (нет на этом раунде — #1013 закрыта по канону, письмо mappa отправлено)
|
||||||
|
|
||||||
## Не делать (guards)
|
## Не делать (guards)
|
||||||
- Редеплой mappa — только через Portainer (`POST /api/stacks/26/{stop,start}`; JWT как в миграц. скрипте). `docker compose up` по SSH — anti-pattern.
|
- Редеплой mappa — только через Portainer (стек 26; `docker pull` ДО restart). `docker compose up` по SSH — anti-pattern.
|
||||||
- Новый тег образа mappa → `docker pull` на VDS ДО restart стека (ловил залипание на старом digest).
|
- Не трогать mappa-скоуп: read-only файлов + фолбэк + поллер на сервисных — шаг ФЛИПА (решение 15, ~2 недели), решает mappa.
|
||||||
- Не дублировать прод-импорт/ntfy/read-only — это скоуп mappa (#985), не админа.
|
- Монитор mappa наблюдает — редеплой/рестарт стека отдельной задачей, не вручную.
|
||||||
- `noreply@snolla.com` — мёртвый ящик, не возвращать в конфиги.
|
- `noreply@snolla.com` — мёртвый ящик, не возвращать в конфиги.
|
||||||
- Не чистить legacy agendaJobs без отдельного запроса.
|
- Не чистить legacy agendaJobs без отдельного запроса.
|
||||||
|
|
||||||
## Memory updates за сессию
|
## 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.
|
- Монитор 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` §Мониторинг живости.
|
||||||
- Кандидат в 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).
|
|
||||||
|
|||||||
@@ -25,9 +25,20 @@
|
|||||||
## Key files
|
## Key files
|
||||||
|
|
||||||
## Decisions log
|
## 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
|
## Open questions
|
||||||
|
|
||||||
## Completed steps
|
## 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
|
## Notes
|
||||||
|
- Деплой: 2026-08-23. Топик mappa-alerts подтверждён в ntfy (vitya admin-role → rw без правки ACL).
|
||||||
@@ -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` в БД.
|
entities/edges/leases/logs, owner=mappa) — `docker logs mappa` + `\dt` в БД.
|
||||||
5. **Пинг mappa**: письмо в `mappa/.agents/inbox/` (event: mappa-deployed) — импорт и остальное за ними.
|
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 (из первых деплоев)
|
## Gotchas (из первых деплоев)
|
||||||
|
|
||||||
1. **schema.sql в dist** — tsc компилирует только .ts; `migrate()` ищет `.sql` рядом с
|
1. **schema.sql в dist** — tsc компилирует только .ts; `migrate()` ищет `.sql` рядом с
|
||||||
|
|||||||
170
scripts/mappa-ntfy-monitor/monitor.py
Normal file
170
scripts/mappa-ntfy-monitor/monitor.py
Normal file
@@ -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())
|
||||||
Reference in New Issue
Block a user