1
0
Fork 0
mirror of https://github.com/EDeev/alertbot.git synced 2026-10-08 04:59:29 +03:00

Compare commits

...

6 commits

Author SHA1 Message Date
Egor Deev
6296fe9123 README на русском и английском, docs/deploy.md с примером nginx
Описание и имя репозитория приведены к содержимому (бот мониторинга, а не будильник).
2026-10-02 19:26:40 +00:00
Egor Deev
391900570a CI: ruff и тесты на каждый push; Docker-образ по тегу в GHCR и dcr.deev.su 2026-10-02 19:25:42 +00:00
Egor Deev
56638f5878 Docker: образ на python:3.12-slim, compose с томом для базы
Лог в файл стал необязательным (ALERTBOT_LOG_FILE): в контейнере пишем только в stdout.
2026-10-02 19:25:42 +00:00
Egor Deev
4a77957e93 Тесты: дедупликация алертов, возврат в норму, сторож, сводки (13 тестов без сети) 2026-10-02 13:54:49 +00:00
Egor Deev
c7d4142438 Зависимости закреплены по рабочему окружению, настройки ruff/pytest 2026-10-02 13:54:49 +00:00
Egor Deev
63c9976888 Токены в заголовке, сторож недоступности мониторинга, путь к базе из окружения
- токены Prometheus/Alertmanager передаются в заголовке Authorization, а не в адресе;
- если Alertmanager не отвечает ALERT_WATCHDOG_FAILURES опросов подряд, бот сообщает
  «Мониторинг недоступен» и отдельно — когда он снова доступен;
- один цикл опроса вынесен в process_alerts(); путь к SQLite — ALERTBOT_DB;
- переводы строк приведены к LF.
2026-10-02 13:54:49 +00:00
21 changed files with 1128 additions and 568 deletions

8
.dockerignore Normal file
View file

@ -0,0 +1,8 @@
.git
.github
**/__pycache__
.env
tests
*.md
*.db
*.log

View file

@ -4,7 +4,8 @@ BOT_TOKEN=123456:your-telegram-bot-token
# Comma-separated Telegram user ids allowed to use the bot # Comma-separated Telegram user ids allowed to use the bot
ALLOWED_IDS=111111111,222222222 ALLOWED_IDS=111111111,222222222
# Prometheus/Alertmanager endpoints (behind your own token-auth reverse-proxy path) # Prometheus/Alertmanager endpoints behind your own reverse proxy.
# The bot sends "Authorization: Bearer <token>" — the proxy must check it.
AM_ALERTS_URL=https://your-domain/ambot/api/v2/alerts AM_ALERTS_URL=https://your-domain/ambot/api/v2/alerts
PROM_QUERY_URL=https://your-domain/prombot/api/v1/query PROM_QUERY_URL=https://your-domain/prombot/api/v1/query
AM_TOKEN=change-me AM_TOKEN=change-me
@ -15,3 +16,8 @@ SERVERS_ORDER=srv1,srv2,srv3
ALERT_POLL_SECONDS=45 ALERT_POLL_SECONDS=45
ALERT_HTTP_TIMEOUT=15 ALERT_HTTP_TIMEOUT=15
# Report "monitoring unreachable" after this many failed polls in a row
ALERT_WATCHDOG_FAILURES=4
# SQLite file with subscriptions and alert state
ALERTBOT_DB=notifications.db

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

22
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,22 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: requirements-dev.txt
- run: pip install -r requirements-dev.txt
- name: Ruff
run: ruff check .
- name: Тесты
run: pytest -q

42
.github/workflows/docker.yml vendored Normal file
View file

@ -0,0 +1,42 @@
name: Docker
on:
push:
tags: ["v*"]
workflow_dispatch:
jobs:
image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/login-action@v3
with:
registry: dcr.deev.su
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
- id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/edeev/alertbot
dcr.deev.su/edeev/alertbot
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

17
Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
ALERTBOT_LOG_FILE= \
PYTHONUNBUFFERED=1 \
ALERTBOT_DB=/data/notifications.db
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY *.py ./
RUN useradd --create-home --uid 1000 app && mkdir -p /data && chown -R app:app /app /data
USER app
VOLUME ["/data"]
CMD ["python", "bot.py"]

109
README.en.md Normal file
View file

@ -0,0 +1,109 @@
# AlertBot
[Русский](README.md) · **English**
[![CI](https://github.com/EDeev/alertbot/actions/workflows/ci.yml/badge.svg)](https://github.com/EDeev/alertbot/actions/workflows/ci.yml)
[![Docker](https://github.com/EDeev/alertbot/actions/workflows/docker.yml/badge.svg)](https://github.com/EDeev/alertbot/actions/workflows/docker.yml)
[![Release](https://img.shields.io/github/v/release/EDeev/alertbot)](https://github.com/EDeev/alertbot/releases)
[![License](https://img.shields.io/github/license/EDeev/alertbot)](LICENSE)
A Telegram bot for monitoring a small server fleet on top of Prometheus and Alertmanager: it sends
"problem / resolved" alerts and, on command, shows the state of servers, services and TLS certificates.
**Status:** personal project, in production · watches the author's five servers
```text
Серверы
SPB
CPU 7% · RAM 41% · диск 38% · swap 0%
load 0.21 · аптайм 12д 4ч
DORM · ⚠ диск
CPU 18% · RAM 63% · диск 87% · swap 2% · temp 52°C
load 1.40 · аптайм 3д 9ч
```
<sub>Sample <code>/servers</code> reply (illustrative values; the bot speaks Russian).</sub>
**Stack:** Python 3.10+ · aiogram 3 · aiohttp · SQLite · Prometheus HTTP API · Alertmanager API v2 · Docker
## Features
- **Alerts:** polls Alertmanager every N seconds, deduplicates by fingerprint and immediately notifies
subscribers about new and resolved alerts. No rules of its own — whatever Alertmanager has
- **Watchdog:** if Alertmanager itself stops responding, the bot reports it once and again when
monitoring is back
- **`/servers`** — CPU, RAM, disk, swap, load, uptime and temperature per node, ⚠ above thresholds
- **`/services`** — which Prometheus targets are down
- **`/certs`** — days until TLS certificates expire, warning under 14 days
- **`/alerts`** — active alerts; `/alerts on|off` — subscription
- Access is limited to a list of Telegram IDs; everyone else is silently ignored
## Quick start
```bash
git clone https://github.com/EDeev/alertbot.git && cd alertbot
cp .env.example .env # bot token, IDs, Prometheus/Alertmanager URLs and tokens
docker compose up -d
```
Prebuilt image: `docker pull ghcr.io/edeev/alertbot` or `docker pull dcr.deev.su/edeev/alertbot`.
## Installing without Docker
```bash
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
python bot.py
```
## Configuration
| Variable | Purpose |
|---|---|
| `BOT_TOKEN` | bot token from @BotFather |
| `ALLOWED_IDS` | comma-separated Telegram IDs allowed to use the bot |
| `AM_ALERTS_URL`, `PROM_QUERY_URL` | Alertmanager `/api/v2/alerts` and Prometheus `/api/v1/query` |
| `AM_TOKEN`, `PROM_TOKEN` | tokens sent in the `Authorization: Bearer` header |
| `SERVERS_ORDER` | comma-separated node names as in the metrics' `server` label |
| `ALERT_POLL_SECONDS`, `ALERT_HTTP_TIMEOUT` | poll interval and request timeout |
| `ALERT_WATCHDOG_FAILURES` | failed polls in a row before reporting monitoring as unreachable (default 4) |
| `ALERTBOT_DB`, `ALERTBOT_LOG_FILE` | SQLite file and log file (empty means stdout only) |
> [!IMPORTANT]
> Do not expose Prometheus and Alertmanager to the internet without authentication. The bot expects a
> reverse proxy that checks the `Authorization: Bearer …` token; see the nginx example in
> [docs/deploy.md](docs/deploy.md) (in Russian).
## Deployment
The bot runs on a separate VPS as a systemd unit. Prometheus and Alertmanager live on another server
behind nginx, which lets the bot in only with a token. GitHub Actions builds the Docker image on every
`v*` tag and publishes it to GitHub Packages and to `dcr.deev.su`.
## Development
```bash
pip install -r requirements-dev.txt
ruff check . && pytest
```
The tests need no network: Telegram and Prometheus are faked. They cover alert deduplication,
resolved notifications, the watchdog and report texts.
## License
MIT — see [LICENSE](LICENSE).
## Author
**Egor Deev** — [GitHub](https://github.com/EDeev) · [Telegram](https://t.me/DeevEgor) · [egor@deev.space](mailto:egor@deev.space)
---
<div align="center">
<sub>⭐ If you find this project useful, give it a star on GitHub!</sub>
<p><sub>Made with ❤️ — <a href="https://deev.space">deev.space</a></sub></p>
</div>

149
README.md
View file

@ -1,84 +1,109 @@
# AlertBot # AlertBot
Telegram-бот для мониторинга парка серверов поверх Prometheus + Alertmanager. Работает как **Русский** · [English](README.en.md)
push-уведомитель («что-то сломалось / починилось») и как справочная панель по команде
(`/servers`, `/services`, `/certs`). [![CI](https://github.com/EDeev/alertbot/actions/workflows/ci.yml/badge.svg)](https://github.com/EDeev/alertbot/actions/workflows/ci.yml)
[![Docker](https://github.com/EDeev/alertbot/actions/workflows/docker.yml/badge.svg)](https://github.com/EDeev/alertbot/actions/workflows/docker.yml)
[![Release](https://img.shields.io/github/v/release/EDeev/alertbot)](https://github.com/EDeev/alertbot/releases)
[![License](https://img.shields.io/github/license/EDeev/alertbot)](LICENSE)
Telegram-бот для мониторинга парка серверов поверх Prometheus и Alertmanager: присылает алерты
«проблема / в норме» и по команде показывает состояние серверов, сервисов и TLS-сертификатов.
**Статус:** личный проект, работает · следит за пятью серверами автора
```text
Серверы
SPB
CPU 7% · RAM 41% · диск 38% · swap 0%
load 0.21 · аптайм 12д 4ч
DORM · ⚠ диск
CPU 18% · RAM 63% · диск 87% · swap 2% · temp 52°C
load 1.40 · аптайм 3д 9ч
```
<sub>Пример ответа на <code>/servers</code> (значения условные).</sub>
**Стек:** Python 3.10+ · aiogram 3 · aiohttp · SQLite · Prometheus HTTP API · Alertmanager API v2 · Docker
## Возможности ## Возможности
- **Алерты в реальном времени** — фоновая задача раз в N секунд опрашивает Alertmanager, - **Алерты:** раз в N секунд опрашивает Alertmanager, дедуплицирует по fingerprint и сразу пишет
дедуплицирует по fingerprint и сразу шлёт «Проблема · …» / «В норме · …» подписанным «Проблема · …» и «В норме · …» подписчикам. Своих правил нет — всё, что настроено в Alertmanager
пользователям. Никаких собственных правил — подхватывает всё, что уже настроено в - **Сторож:** если сам Alertmanager перестал отвечать, бот один раз сообщает об этом и ещё раз —
Alertmanager, по лейблам, а не по именам целей. когда мониторинг вернулся
- **`/servers`** — CPU, RAM, диск, swap, load, аптайм и (если есть hwmon-датчики) - **`/servers`** — CPU, RAM, диск, swap, load, аптайм и температура по узлам, ⚠ при превышении порогов
температура по каждому узлу, с ⚠ при превышении порогов. - **`/services`** — какие цели Prometheus не отвечают
- **`/services`** — какие цели Prometheus сейчас `up`, какие нет. - **`/certs`** — дни до истечения TLS-сертификатов, предупреждение меньше чем за 14 дней
- **`/certs`** — сколько дней осталось у каждого TLS-сертификата, с предупреждением при <14 дней. - **`/alerts`** — активные алерты; `/alerts on|off` — подписка
- **`/alerts`** — список активных алертов сейчас; `/alerts on|off` — подписка/отписка. - Доступ — только для Telegram ID из списка, остальные молча игнорируются
- Доступ — по вайтлисту Telegram user id, все остальные тихо игнорируются.
## Требования ## Быстрый старт
- Python 3.10+
- Свой Prometheus + Alertmanager с уже настроенными правилами алертов.
- Alertmanager и Prometheus должны быть доступны боту по HTTP — либо напрямую (если бот
крутится на той же машине), либо через reverse-proxy с токен-параметром в URL (так это
сделано в этом проекте — см. `AM_ALERTS_URL`/`PROM_QUERY_URL`/`AM_TOKEN`/`PROM_TOKEN`
в `.env.example`), чтобы не открывать сами Prometheus/Alertmanager в интернет без авторизации.
## Установка
```bash ```bash
git clone https://github.com/EDeev/alertbot.git && cd alertbot
cp .env.example .env # токен бота, ID, адреса и токены Prometheus/Alertmanager
docker compose up -d
```
Готовый образ: `docker pull ghcr.io/edeev/alertbot` или `docker pull dcr.deev.su/edeev/alertbot`.
## Установка без Docker
```bash
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
cp .env.example .env cp .env.example .env
# заполнить .env своими значениями
python bot.py python bot.py
``` ```
## Переменные окружения ## Конфигурация
Все — в `.env.example`: | Переменная | Назначение |
|---|---|
| `BOT_TOKEN` | токен бота от @BotFather |
| `ALLOWED_IDS` | Telegram ID через запятую, кому можно пользоваться ботом |
| `AM_ALERTS_URL`, `PROM_QUERY_URL` | Alertmanager `/api/v2/alerts` и Prometheus `/api/v1/query` |
| `AM_TOKEN`, `PROM_TOKEN` | токены, бот передаёт их в заголовке `Authorization: Bearer` |
| `SERVERS_ORDER` | имена узлов через запятую, как в лейбле `server` метрик |
| `ALERT_POLL_SECONDS`, `ALERT_HTTP_TIMEOUT` | период опроса и таймаут запросов |
| `ALERT_WATCHDOG_FAILURES` | после скольких неудачных опросов подряд сообщать о недоступности (по умолчанию 4) |
| `ALERTBOT_DB`, `ALERTBOT_LOG_FILE` | файл SQLite и файл лога (пусто — только stdout) |
- `BOT_TOKEN` — токен бота от @BotFather. > [!IMPORTANT]
- `ALLOWED_IDS` — Telegram user id через запятую, кому разрешено пользоваться ботом. > Не открывайте Prometheus и Alertmanager в интернет без авторизации. Бот рассчитан на обратный прокси,
- `AM_ALERTS_URL` / `PROM_QUERY_URL` / `AM_TOKEN` / `PROM_TOKEN` — адреса и токены до > который проверяет токен из заголовка `Authorization: Bearer …`, — пример для nginx в
Alertmanager API (`/api/v2/alerts`) и Prometheus API (`/api/v1/query`). > [docs/deploy.md](docs/deploy.md).
- `SERVERS_ORDER` — имена узлов через запятую, ровно как они указаны в лейбле `server`
у твоих метрик (`node_exporter` и т.п.) — определяет порядок и состав вывода `/servers`.
- `ALERT_POLL_SECONDS` / `ALERT_HTTP_TIMEOUT` — период опроса и таймаут запросов.
## Структура ## Развёртывание
``` Бот работает на отдельном VPS как systemd-юнит. Prometheus и Alertmanager стоят на другом сервере
init.py # конфиг из переменных окружения, инициализация Bot/Dispatcher за nginx, который пускает бота только с токеном. Docker-образ собирает GitHub Actions на каждый тег
sql.py # SQLite: подписчики на алерты, дедуп по fingerprint `v*` и публикует в GitHub Packages и в реестр `dcr.deev.su`.
handlers.py # команды бота + фоновый опрос Alertmanager
bot.py # точка входа ## Разработка
```bash
pip install -r requirements-dev.txt
ruff check . && pytest
``` ```
Хранилище — один файл SQLite (`notifications.db`, путь по умолчанию задаётся при Тесты работают без сети: Telegram и Prometheus подменяются. Проверяются дедупликация алертов,
инициализации `DatabaseManager`), без внешних зависимостей вроде Redis/Postgres. сообщения о возврате в норму, сторож и тексты сводок.
## Продакшен
Юнит `systemd` — самый простой способ держать бота в фоне постоянно:
```ini
[Unit]
Description=AlertBot
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/alertbot
ExecStart=/opt/alertbot/venv/bin/python3 bot.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
## Лицензия ## Лицензия
MIT MIT — см. [LICENSE](LICENSE).
## Автор
**Деев Егор Викторович** — [GitHub](https://github.com/EDeev) · [Telegram](https://t.me/DeevEgor) · [egor@deev.space](mailto:egor@deev.space)
---
<div align="center">
<sub>⭐ Если проект оказался полезным, поставьте звёздочку на GitHub!</sub>
<p><sub>Сделано с ❤️ — <a href="https://deev.space">deev.space</a></sub></p>
</div>

8
bot.py
View file

@ -1,5 +1,6 @@
import asyncio import asyncio
import logging import logging
import os
import sys import sys
from aiogram.types import BotCommand from aiogram.types import BotCommand
@ -7,11 +8,14 @@ from aiogram.types import BotCommand
from init import bot, dp from init import bot, dp
from handlers import router, alert_poller from handlers import router, alert_poller
_log_handlers = [logging.StreamHandler(sys.stdout)]
# File log is optional: in Docker set ALERTBOT_LOG_FILE= (empty) and read `docker logs`
if os.environ.get("ALERTBOT_LOG_FILE", "bot.log"):
_log_handlers.append(logging.FileHandler(os.environ.get("ALERTBOT_LOG_FILE", "bot.log"), encoding="utf-8"))
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler(sys.stdout), handlers=_log_handlers,
logging.FileHandler('bot.log', encoding='utf-8')],
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)

14
compose.yaml Normal file
View file

@ -0,0 +1,14 @@
services:
bot:
build: .
image: ghcr.io/edeev/alertbot:latest
env_file: .env
environment:
ALERTBOT_DB: /data/notifications.db
ALERTBOT_LOG_FILE: ""
volumes:
- data:/data
restart: unless-stopped
volumes:
data:

58
docs/deploy.md Normal file
View file

@ -0,0 +1,58 @@
# Развёртывание
## Docker
```bash
cp .env.example .env
docker compose up -d
docker compose logs -f bot
```
Подписки и состояние алертов хранятся в томе `data` (`/data/notifications.db`).
## systemd
```ini
[Unit]
Description=AlertBot
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/alertbot
ExecStart=/opt/alertbot/venv/bin/python bot.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
## Обратный прокси перед Prometheus и Alertmanager (nginx)
Бот отправляет токен в заголовке `Authorization: Bearer <токен>`. Пример проверки:
```nginx
# в контексте http (например, /etc/nginx/conf.d/alertbot-auth.conf)
map $http_authorization $alertbot_auth_ok {
default 0;
"Bearer ВАШ_ТОКЕН" 1;
}
# в server { ... }
location /prombot/ {
if ($alertbot_auth_ok = 0) { return 403; }
proxy_pass http://127.0.0.1:9090/;
}
location /ambot/ {
if ($alertbot_auth_ok = 0) { return 403; }
proxy_pass http://127.0.0.1:9093/;
}
```
## Что ожидается от Prometheus
- у метрик node_exporter есть лейбл `server` с короткими именами узлов (как в `SERVERS_ORDER`);
- джобы node_exporter называются `node_*`;
- для `/certs` — blackbox exporter с метрикой `probe_ssl_earliest_cert_expiry`;
- температура берётся из `node_hwmon_temp_celsius` с `chip=~"pci.*"` (настоящие датчики).

View file

@ -10,12 +10,12 @@ from aiogram import BaseMiddleware, Router
from aiogram.filters import Command, CommandObject from aiogram.filters import Command, CommandObject
from aiogram.types import Message from aiogram.types import Message
from init import (ALERT_HTTP_TIMEOUT, ALERT_POLL_SECONDS, ALLOWED_IDS, AM_ALERTS_URL, from init import (ALERT_HTTP_TIMEOUT, ALERT_POLL_SECONDS, ALERT_WATCHDOG_FAILURES, ALLOWED_IDS,
AM_TOKEN, PROM_QUERY_URL, PROM_TOKEN, bot) AM_ALERTS_URL, AM_TOKEN, DB_PATH, PROM_QUERY_URL, PROM_TOKEN, bot)
from sql import DatabaseManager from sql import DatabaseManager
router = Router() router = Router()
db = DatabaseManager() db = DatabaseManager(DB_PATH)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Node names as used in Prometheus' "server" label — match your own scrape config. # Node names as used in Prometheus' "server" label — match your own scrape config.
@ -128,8 +128,14 @@ async def cmd_certs(msg: Message):
# ====================================================================== # ======================================================================
# Prometheus # Prometheus
# ====================================================================== # ======================================================================
def _auth(token: str) -> dict:
"""Token goes in the Authorization header, not in the URL (URLs end up in proxy logs)."""
return {"Authorization": f"Bearer {token}"}
async def _promq(session: aiohttp.ClientSession, query: str) -> List[dict]: async def _promq(session: aiohttp.ClientSession, query: str) -> List[dict]:
async with session.get(PROM_QUERY_URL, params={"query": query, "token": PROM_TOKEN}, async with session.get(PROM_QUERY_URL, params={"query": query},
headers=_auth(PROM_TOKEN),
timeout=aiohttp.ClientTimeout(total=ALERT_HTTP_TIMEOUT)) as r: timeout=aiohttp.ClientTimeout(total=ALERT_HTTP_TIMEOUT)) as r:
r.raise_for_status() r.raise_for_status()
j = await r.json() j = await r.json()
@ -277,8 +283,8 @@ def _fmt_resolved(name: str) -> str:
async def _fetch_alerts(session: aiohttp.ClientSession) -> List[dict]: async def _fetch_alerts(session: aiohttp.ClientSession) -> List[dict]:
params = {"token": AM_TOKEN, "active": "true", "silenced": "false", "inhibited": "false"} params = {"active": "true", "silenced": "false", "inhibited": "false"}
async with session.get(AM_ALERTS_URL, params=params, async with session.get(AM_ALERTS_URL, params=params, headers=_auth(AM_TOKEN),
timeout=aiohttp.ClientTimeout(total=ALERT_HTTP_TIMEOUT)) as r: timeout=aiohttp.ClientTimeout(total=ALERT_HTTP_TIMEOUT)) as r:
r.raise_for_status() r.raise_for_status()
return await r.json() return await r.json()
@ -304,12 +310,17 @@ async def render_active_alerts() -> str:
return f"<b>Активные алерты: {len(firing)}</b>\n\n" + "\n\n".join(_fmt_firing(a) for a in firing) return f"<b>Активные алерты: {len(firing)}</b>\n\n" + "\n\n".join(_fmt_firing(a) for a in firing)
async def alert_poller(): def _fmt_watchdog_down(error: str) -> str:
log.info("alert poller started (every %ss)", ALERT_POLL_SECONDS) return ("<b>Мониторинг недоступен</b>\nAlertmanager не отвечает, о новых проблемах бот сейчас не узнает.\n"
async with aiohttp.ClientSession() as session: f"Последняя ошибка: {html.escape(error)}")
while True:
try:
data = await _fetch_alerts(session) def _fmt_watchdog_up() -> str:
return "<b>Мониторинг снова доступен</b>"
async def process_alerts(data: List[dict]) -> None:
"""One poll: announce new firing alerts and alerts that have resolved since the last poll."""
current = {} current = {}
for a in data: for a in data:
if a.get("status", {}).get("state") != "active": if a.get("status", {}).get("state") != "active":
@ -326,8 +337,40 @@ async def alert_poller():
await _broadcast(_fmt_resolved(name)) await _broadcast(_fmt_resolved(name))
db.upsert_alert(fp, "resolved", name) db.upsert_alert(fp, "resolved", name)
db.purge_old_resolved() db.purge_old_resolved()
class Watchdog:
"""Counts failed polls in a row; reports once when monitoring is lost and once when it is back."""
def __init__(self, threshold: int):
self.threshold = threshold
self.failures = 0
self.reported = False
async def failed(self, error: str) -> None:
self.failures += 1
if self.failures >= self.threshold and not self.reported:
self.reported = True
await _broadcast(_fmt_watchdog_down(error))
async def ok(self) -> None:
if self.reported:
await _broadcast(_fmt_watchdog_up())
self.failures = 0
self.reported = False
async def alert_poller():
log.info("alert poller started (every %ss)", ALERT_POLL_SECONDS)
watchdog = Watchdog(ALERT_WATCHDOG_FAILURES)
async with aiohttp.ClientSession() as session:
while True:
try:
await process_alerts(await _fetch_alerts(session))
await watchdog.ok()
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: except Exception as e:
log.warning("alert poll failed: %s", e) log.warning("alert poll failed: %s", e)
await watchdog.failed(str(e) or type(e).__name__)
await asyncio.sleep(ALERT_POLL_SECONDS) await asyncio.sleep(ALERT_POLL_SECONDS)

View file

@ -20,6 +20,9 @@ AM_TOKEN = os.environ["AM_TOKEN"]
PROM_TOKEN = os.environ["PROM_TOKEN"] PROM_TOKEN = os.environ["PROM_TOKEN"]
ALERT_POLL_SECONDS = int(os.environ.get("ALERT_POLL_SECONDS", "45")) ALERT_POLL_SECONDS = int(os.environ.get("ALERT_POLL_SECONDS", "45"))
ALERT_HTTP_TIMEOUT = int(os.environ.get("ALERT_HTTP_TIMEOUT", "15")) ALERT_HTTP_TIMEOUT = int(os.environ.get("ALERT_HTTP_TIMEOUT", "15"))
# After this many failed polls in a row the bot reports that monitoring itself is unreachable
ALERT_WATCHDOG_FAILURES = int(os.environ.get("ALERT_WATCHDOG_FAILURES", "4"))
DB_PATH = os.environ.get("ALERTBOT_DB", "notifications.db")
bot = Bot(token=BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) bot = Bot(token=BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
dp = Dispatcher(storage=MemoryStorage()) dp = Dispatcher(storage=MemoryStorage())

13
pyproject.toml Normal file
View file

@ -0,0 +1,13 @@
[tool.ruff]
target-version = "py310"
line-length = 120
[tool.ruff.lint]
select = ["E", "F", "W", "B", "S"]
ignore = ["E501", "E701"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101", "S105", "S106"]
[tool.pytest.ini_options]
testpaths = ["tests"]

3
requirements-dev.txt Normal file
View file

@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.4.2
ruff==0.14.0

View file

@ -1,3 +1,3 @@
aiogram>=3.0 aiogram==3.31.0
aiohttp aiohttp==3.14.3
python-dotenv python-dotenv==1.2.1

0
tests/__init__.py Normal file
View file

62
tests/conftest.py Normal file
View file

@ -0,0 +1,62 @@
import os
import sys
import tempfile
from pathlib import Path
os.environ.update({
"BOT_TOKEN": "123456:TEST-token-for-unit-tests-only",
"ALLOWED_IDS": "1,2",
"AM_ALERTS_URL": "https://example.test/ambot/api/v2/alerts",
"PROM_QUERY_URL": "https://example.test/prombot/api/v1/query",
"AM_TOKEN": "am-token",
"PROM_TOKEN": "prom-token",
"SERVERS_ORDER": "spb,ams,dorm",
"ALERTBOT_DB": str(Path(tempfile.mkdtemp()) / "test.db"),
"ALERT_WATCHDOG_FAILURES": "3",
})
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import pytest # noqa: E402
import handlers # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db():
with handlers.db._conn() as conn:
conn.execute("DELETE FROM users")
conn.execute("DELETE FROM alert_seen")
handlers.db.add_user(1, "owner")
handlers.db.add_user(2, "friend")
handlers.db.set_alerts(2, False)
@pytest.fixture
def sent(monkeypatch):
"""Messages the bot would send: (user_id, text)."""
out = []
async def fake_send(uid, text):
out.append((uid, text))
monkeypatch.setattr(handlers.bot, "send_message", fake_send)
return out
@pytest.fixture
def prom(monkeypatch):
"""Fake Prometheus: maps a substring of the query to a result vector."""
answers = {}
async def fake_promq(session, query):
for key, value in answers.items():
if key in query:
return value
return []
monkeypatch.setattr(handlers, "_promq", fake_promq)
return answers
def vec(**by_server):
return [{"metric": {"server": k}, "value": [0, str(v)]} for k, v in by_server.items()]

61
tests/test_alerts.py Normal file
View file

@ -0,0 +1,61 @@
import asyncio
import handlers
def alert(fp, name="NodeDown", state="active", server="spb", summary="Узел не отвечает"):
return {"fingerprint": fp, "status": {"state": state},
"labels": {"alertname": name, "severity": "critical", "server": server},
"annotations": {"summary": summary}}
def run(coro):
return asyncio.run(coro)
def test_new_alert_is_sent_once_to_subscribers(sent):
run(handlers.process_alerts([alert("a1")]))
run(handlers.process_alerts([alert("a1")]))
assert [uid for uid, _ in sent] == [1]
assert "Проблема · NodeDown" in sent[0][1]
def test_resolved_alert_is_announced(sent):
run(handlers.process_alerts([alert("a1")]))
run(handlers.process_alerts([]))
assert "В норме · NodeDown" in sent[-1][1]
assert handlers.db.list_firing_fingerprints() == []
def test_suppressed_alerts_are_ignored(sent):
run(handlers.process_alerts([alert("a1", state="suppressed"), {"status": {"state": "active"}}]))
assert sent == []
def test_alert_text_is_html_escaped():
text = handlers._fmt_firing(alert("a1", name="<b>x</b>", summary="a < b & c"))
assert "&lt;b&gt;x&lt;/b&gt;" in text
assert "a &lt; b &amp; c" in text
def test_watchdog_reports_once_and_recovers(sent):
wd = handlers.Watchdog(threshold=3)
for _ in range(5):
run(wd.failed("timeout"))
assert len(sent) == 1 and "Мониторинг недоступен" in sent[0][1]
run(wd.ok())
assert "Мониторинг снова доступен" in sent[-1][1]
run(wd.ok())
assert len(sent) == 2
def test_watchdog_ignores_short_glitches(sent):
wd = handlers.Watchdog(threshold=3)
run(wd.failed("timeout"))
run(wd.failed("timeout"))
run(wd.ok())
assert sent == []
def test_token_is_sent_in_header_not_url():
assert handlers._auth("secret") == {"Authorization": "Bearer secret"}

69
tests/test_reports.py Normal file
View file

@ -0,0 +1,69 @@
import asyncio
import handlers
from tests.conftest import vec
def run(coro):
return asyncio.run(coro)
def test_uptime_format():
assert handlers.fmt_uptime(3 * 86400 + 5 * 3600) == "3д 5ч"
assert handlers.fmt_uptime(7200) == "2ч"
assert handlers.fmt_uptime(-5) == "0ч"
def test_servers_report_marks_problems(prom):
import time
now = time.time()
prom.update({
"up{": vec(spb=1, ams=0),
"node_cpu_seconds_total": vec(spb=12, ams=3),
"MemAvailable": vec(spb=95, ams=40),
"node_filesystem_avail_bytes": vec(spb=50, ams=91),
"node_load1": vec(spb=0.5, ams=0.1),
"node_boot_time_seconds": vec(spb=now - 2 * 86400, ams=now - 3600),
"SwapTotal": vec(spb=0, ams=0),
"hwmon": vec(spb=40),
})
text = run(handlers.render_servers())
assert "<b>SPB</b> · ⚠ память" in text
assert "<b>AMS</b> · НЕ ОТВЕЧАЕТ" in text
assert "<b>DORM</b> — нет данных" in text
assert "аптайм 2д 0ч" in text
def test_services_report(prom):
prom["up"] = [
{"metric": {"job": "node_spb", "instance": "spb:9100"}, "value": [0, "1"]},
{"metric": {"job": "blackbox_http", "instance": "https://<bad>.example"}, "value": [0, "0"]},
]
text = run(handlers.render_services())
assert "1/2 в норме" in text
assert "&lt;bad&gt;" in text
def test_services_all_ok(prom):
prom["up"] = [{"metric": {"job": "node_spb"}, "value": [0, "1"]}]
assert "Все 1 в норме" in run(handlers.render_services())
def test_certs_report_sorted_with_warning(prom):
prom["probe_ssl_earliest_cert_expiry"] = [
{"metric": {"instance": "https://deev.space/"}, "value": [0, "60.2"]},
{"metric": {"instance": "https://tablo.deev.su"}, "value": [0, "5.1"]},
]
text = run(handlers.render_certs())
assert "скоро истекают: tablo.deev.su — 5 дн" in text
rows = text.split("<pre>")[1].split("</pre>")[0].splitlines()
assert rows[0].endswith("tablo.deev.su") and rows[1].endswith("deev.space")
def test_prometheus_error_is_reported(monkeypatch):
async def boom(session, query):
raise RuntimeError("connection refused")
monkeypatch.setattr(handlers, "_promq", boom)
assert "Не удалось получить метрики" in run(handlers.render_servers())