Initial commit: Message Gateway project

- FastAPI приложение для отправки мониторинговых алертов в мессенджеры
- Поддержка Telegram и MAX/VK
- Интеграция с Grafana, Zabbix, AlertManager
- Автоматическое создание тикетов в Jira
- Управление группами мессенджеров через API
- Декораторы для авторизации и скрытия эндпоинтов
- Подробная документация в папке docs/

Автор: Сергей Антропов
Сайт: https://devops.org.ru
This commit is contained in:
2025-11-12 20:25:11 +03:00
commit b90def35ed
72 changed files with 10609 additions and 0 deletions

87
.gitignore vendored Normal file
View File

@@ -0,0 +1,87 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual Environment
venv/
env/
ENV/
.venv
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Environment variables
.env
.env.local
.env.*.local
# Logs
*.log
logs/
# Database
*.db
*.sqlite
*.sqlite3
# Docker
.dockerignore
# Application specific
/app/app/dump.json
dump.json
*.dump
# Kubernetes
*.kubeconfig
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
.hypothesis/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# Project specific
config/groups.json
config/jira_mapping.json

45
Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# Dockerfile для Telegram Gateway
# Автор: Сергей Антропов
# Сайт: https://devops.org.ru
FROM python:3.11-slim
# Установка переменных окружения
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Создание рабочей директории
WORKDIR /app
# Установка системных зависимостей
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Копирование файлов зависимостей
COPY requirements.txt .
# Установка Python зависимостей
RUN pip install --upgrade pip && \
pip install -r requirements.txt
# Копирование приложения
COPY ./app ./app
COPY ./templates ./templates
COPY ./config ./config
# Создание пользователя для запуска приложения
RUN useradd -m -u 1000 appuser && \
chown -R appuser:appuser /app
# Переключение на непривилегированного пользователя
USER appuser
# Открытие порта
EXPOSE 8000
# Команда запуска приложения
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

534
Makefile Normal file
View File

@@ -0,0 +1,534 @@
# Makefile для Message Gateway
# Автор: Сергей Антропов
# Сайт: https://devops.org.ru
.PHONY: help build up up-build down restart logs shell test clean clean-all lint format install dev stop status health ready version psql redis-cli
.PHONY: docker docker-build docker-tag docker-push docker-run docker-stop docker-logs docker-shell
.PHONY: env env-check env-create env-create-force
.PHONY: git git-status git-pull git-push git-commit git-add git-branch
.PHONY: k8s k8s-apply k8s-delete k8s-status k8s-logs k8s-shell k8s-contexts k8s-set-context
# Цвета для вывода
COLOR_RESET := \033[0m
COLOR_BOLD := \033[1m
COLOR_RED := \033[31m
COLOR_GREEN := \033[32m
COLOR_YELLOW := \033[33m
COLOR_BLUE := \033[34m
COLOR_MAGENTA := \033[35m
COLOR_CYAN := \033[36m
# Переменные
DOCKER_COMPOSE := docker compose
DOCKER := docker
PYTHON := python3
APP_NAME := message-gateway
IMAGE_NAME := message-gateway
REGISTRY := hub.cism-ms.ru/library
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "v0.1.0")
TAG := $(VERSION)
KUBECTL := kubectl
K8S_CONTEXT := $(shell $(KUBECTL) config current-context 2>/dev/null || echo "")
K8S_NAMESPACE := message-gateway
K8S_MANIFEST := kubernetes.yaml
GIT := git
# Проверка наличия .env файла
ENV_FILE := .env
ifeq ($(wildcard $(ENV_FILE)),)
$(warning $(COLOR_YELLOW)Файл .env не найден. Создайте его на основе env.example$(COLOR_RESET))
endif
# ============================================================================
# Помощь
# ============================================================================
help: ## 📖 Показать справку по командам
@echo "$(COLOR_BOLD)$(COLOR_CYAN)Message Gateway - Доступные команды:$(COLOR_RESET)"
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Основные команды:$(COLOR_RESET)"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | grep -v "^[[:space:]]*#" | awk 'BEGIN {FS = ":.*?## "}; {if ($$1 ~ /^(build|up-build|up|down|restart|logs|shell|stop|status|health|ready)$$/) printf " $(COLOR_GREEN)%-25s$(COLOR_RESET) %s\n", $$1, $$2}'
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Разработка:$(COLOR_RESET)"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | grep -v "^[[:space:]]*#" | awk 'BEGIN {FS = ":.*?## "}; {if ($$1 ~ /^(dev|test|lint|format|install|clean|clean-all)$$/) printf " $(COLOR_GREEN)%-25s$(COLOR_RESET) %s\n", $$1, $$2}'
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Docker команды:$(COLOR_RESET)"
@echo " $(COLOR_GREEN)make docker build$(COLOR_RESET) 🐳 Собрать Docker образ"
@echo " $(COLOR_GREEN)make docker tag$(COLOR_RESET) 🏷️ Тегировать Docker образ"
@echo " $(COLOR_GREEN)make docker push$(COLOR_RESET) 📤 Отправить образ в registry"
@echo " $(COLOR_GREEN)make docker run$(COLOR_RESET) 🏃 Запустить контейнер"
@echo " $(COLOR_GREEN)make docker stop$(COLOR_RESET) 🛑 Остановить контейнер"
@echo " $(COLOR_GREEN)make docker logs$(COLOR_RESET) 📋 Показать логи контейнера"
@echo " $(COLOR_GREEN)make docker shell$(COLOR_RESET) 🐚 Открыть shell в контейнере"
@echo " $(COLOR_YELLOW)Использование: make docker CMD=build$(COLOR_RESET)"
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Git команды:$(COLOR_RESET)"
@echo " $(COLOR_GREEN)make git status$(COLOR_RESET) 📊 Показать статус репозитория"
@echo " $(COLOR_GREEN)make git pull$(COLOR_RESET) ⬇️ Получить изменения из удаленного репозитория"
@echo " $(COLOR_GREEN)make git push$(COLOR_RESET) ⬆️ Отправить изменения в удаленный репозиторий"
@echo " $(COLOR_GREEN)make git add [file]$(COLOR_RESET) Добавить файлы в индекс"
@echo " $(COLOR_GREEN)make git commit [msg]$(COLOR_RESET) 💾 Создать коммит"
@echo " $(COLOR_GREEN)make git branch$(COLOR_RESET) 🌿 Показать список веток"
@echo " $(COLOR_YELLOW)Использование: make git CMD=push ARGS=origin main$(COLOR_RESET)"
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Kubernetes команды:$(COLOR_RESET)"
@echo " $(COLOR_GREEN)make k8s contexts$(COLOR_RESET) 📋 Показать доступные контексты"
@echo " $(COLOR_GREEN)make k8s set-context [name]$(COLOR_RESET) 🔄 Установить контекст"
@echo " $(COLOR_GREEN)make k8s apply$(COLOR_RESET) ✅ Применить манифесты"
@echo " $(COLOR_GREEN)make k8s delete$(COLOR_RESET) 🗑️ Удалить ресурсы"
@echo " $(COLOR_GREEN)make k8s status$(COLOR_RESET) 📊 Показать статус подов"
@echo " $(COLOR_GREEN)make k8s logs$(COLOR_RESET) 📋 Показать логи подов"
@echo " $(COLOR_GREEN)make k8s shell$(COLOR_RESET) 🐚 Открыть shell в поде"
@echo " $(COLOR_YELLOW)Использование: make k8s CMD=apply CONTEXT=<контекст>$(COLOR_RESET)"
@if [ -n "$(K8S_CONTEXT)" ]; then \
echo " $(COLOR_CYAN)Текущий контекст: $(COLOR_BOLD)$(K8S_CONTEXT)$(COLOR_RESET)"; \
fi
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Env команды:$(COLOR_RESET)"
@echo " $(COLOR_GREEN)make env check$(COLOR_RESET) 🔍 Проверить наличие .env файла"
@echo " $(COLOR_GREEN)make env create$(COLOR_RESET) 📝 Создать .env файл из env.example"
@echo " $(COLOR_GREEN)make env create-force$(COLOR_RESET) 📝 Принудительно создать .env файл"
@echo " $(COLOR_YELLOW)Использование: make env CMD=check$(COLOR_RESET)"
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Утилиты:$(COLOR_RESET)"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | grep -v "^[[:space:]]*#" | awk 'BEGIN {FS = ":.*?## "}; {if ($$1 ~ /^(version|psql|redis-cli)$$/) printf " $(COLOR_GREEN)%-25s$(COLOR_RESET) %s\n", $$1, $$2}'
@echo ""
# ============================================================================
# Основные команды
# ============================================================================
build: ## 🔨 Собрать Docker образ
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Сборка Docker образа...$(COLOR_RESET)"
@$(DOCKER_COMPOSE) build --no-cache
@echo "$(COLOR_GREEN)✓ Образ собран успешно$(COLOR_RESET)"
up: ## 🚀 Запустить приложение
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Запуск приложения...$(COLOR_RESET)"
@$(DOCKER_COMPOSE) up -d
@echo "$(COLOR_GREEN)✓ Приложение запущено$(COLOR_RESET)"
@echo "$(COLOR_CYAN)Для просмотра логов: $(COLOR_BOLD)make logs$(COLOR_RESET)"
up-build: build up ## 🔨 Собрать и запустить приложение
@echo "$(COLOR_GREEN)✓ Приложение собрано и запущено$(COLOR_RESET)"
down: ## 🛑 Остановить приложение
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Остановка приложения...$(COLOR_RESET)"
@$(DOCKER_COMPOSE) down
@echo "$(COLOR_GREEN)✓ Приложение остановлено$(COLOR_RESET)"
restart: ## 🔄 Перезапустить приложение
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Перезапуск приложения...$(COLOR_RESET)"
@$(DOCKER_COMPOSE) restart
@echo "$(COLOR_GREEN)✓ Приложение перезапущено$(COLOR_RESET)"
logs: ## 📋 Показать логи приложения
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Просмотр логов приложения...$(COLOR_RESET)"
@clear || true
@$(DOCKER_COMPOSE) logs -f --tail=50 $(APP_NAME)
shell: ## 🐚 Открыть shell в контейнере
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Открытие shell в контейнере...$(COLOR_RESET)"
@clear || true
@$(DOCKER_COMPOSE) exec $(APP_NAME) /bin/bash
stop: ## ⏹️ Остановить приложение (alias для down)
@$(MAKE) down
status: ## 📊 Показать статус контейнеров
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Статус контейнеров:$(COLOR_RESET)"
@$(DOCKER_COMPOSE) ps
health: ## ❤️ Проверить здоровье приложения
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Проверка здоровья приложения...$(COLOR_RESET)"
@curl -f http://localhost:8000/api/v1/health || (echo "$(COLOR_RED)✗ Приложение не отвечает$(COLOR_RESET)" && exit 1)
@echo "$(COLOR_GREEN)✓ Приложение здорово$(COLOR_RESET)"
ready: ## ✅ Проверить готовность приложения
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Проверка готовности приложения...$(COLOR_RESET)"
@curl -f http://localhost:8000/api/v1/health/ready || (echo "$(COLOR_RED)✗ Приложение не готово$(COLOR_RESET)" && exit 1)
@echo "$(COLOR_GREEN)✓ Приложение готово$(COLOR_RESET)"
# ============================================================================
# Разработка
# ============================================================================
dev: ## 🔧 Запустить в режиме разработки
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Запуск в режиме разработки...$(COLOR_RESET)"
@$(DOCKER_COMPOSE) up --build
test: ## 🧪 Запустить тесты
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Запуск тестов...$(COLOR_RESET)"
@if [ -f docker-compose.test.yaml ]; then \
$(DOCKER_COMPOSE) -f docker-compose.test.yaml up --abort-on-container-exit; \
else \
echo "$(COLOR_YELLOW)⚠ Файл docker-compose.test.yaml не найден$(COLOR_RESET)"; \
echo "$(COLOR_CYAN)Запуск тестов через pytest...$(COLOR_RESET)"; \
$(DOCKER_COMPOSE) exec $(APP_NAME) pytest -v || true; \
fi
lint: ## 🔍 Запустить линтер
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Запуск линтера...$(COLOR_RESET)"
@$(DOCKER_COMPOSE) exec $(APP_NAME) python -m pylint app/ || echo "$(COLOR_YELLOW)⚠ Линтер не установлен или найдены ошибки$(COLOR_RESET)"
format: ## 🎨 Форматировать код
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Форматирование кода...$(COLOR_RESET)"
@$(DOCKER_COMPOSE) exec $(APP_NAME) python -m black app/ || echo "$(COLOR_YELLOW)⚠ Black не установлен$(COLOR_RESET)"
install: ## 📦 Установить зависимости
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Установка зависимостей...$(COLOR_RESET)"
@pip install -r requirements.txt || $(PYTHON) -m pip install -r requirements.txt
@echo "$(COLOR_GREEN)✓ Зависимости установлены$(COLOR_RESET)"
clean: ## 🧹 Очистить Docker образы и контейнеры
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Очистка Docker ресурсов...$(COLOR_RESET)"
@$(DOCKER_COMPOSE) down -v
@$(DOCKER) system prune -f
@echo "$(COLOR_GREEN)✓ Очистка завершена$(COLOR_RESET)"
clean-all: ## 🗑️ Очистить все Docker ресурсы (включая volumes)
@echo "$(COLOR_BOLD)$(COLOR_RED)Очистка всех Docker ресурсов...$(COLOR_RESET)"
@$(DOCKER_COMPOSE) down -v --rmi all
@$(DOCKER) system prune -af --volumes
@echo "$(COLOR_GREEN)✓ Полная очистка завершена$(COLOR_RESET)"
# ============================================================================
# Docker команды (подкоманды через make docker CMD=<cmd>)
# ============================================================================
docker: ## 🐳 Docker команды (используйте: make docker CMD=build|tag|push|run|stop|logs|shell)
@if [ -z "$(CMD)" ]; then \
echo "$(COLOR_BOLD)$(COLOR_CYAN)Docker команды:$(COLOR_RESET)"; \
echo ""; \
echo " $(COLOR_GREEN)make docker build$(COLOR_RESET) - Собрать Docker образ"; \
echo " $(COLOR_GREEN)make docker tag$(COLOR_RESET) - Тегировать Docker образ"; \
echo " $(COLOR_GREEN)make docker push$(COLOR_RESET) - Отправить образ в registry"; \
echo " $(COLOR_GREEN)make docker run$(COLOR_RESET) - Запустить контейнер"; \
echo " $(COLOR_GREEN)make docker stop$(COLOR_RESET) - Остановить контейнер"; \
echo " $(COLOR_GREEN)make docker logs$(COLOR_RESET) - Показать логи контейнера"; \
echo " $(COLOR_GREEN)make docker shell$(COLOR_RESET) - Открыть shell в контейнере"; \
echo ""; \
echo "$(COLOR_YELLOW)Использование:$(COLOR_RESET)"; \
echo " $(COLOR_CYAN)make docker CMD=build$(COLOR_RESET)"; \
echo " $(COLOR_CYAN)make docker-build$(COLOR_RESET) (прямой вызов)"; \
else \
$(MAKE) docker-$(CMD); \
fi
docker-build: ## 🐳 Собрать Docker образ
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Сборка Docker образа...$(COLOR_RESET)"
@$(DOCKER) build --no-cache -t $(IMAGE_NAME):$(TAG) .
@echo "$(COLOR_GREEN)✓ Образ собран: $(IMAGE_NAME):$(TAG)$(COLOR_RESET)"
docker-tag: docker-build ## 🏷️ Тегировать Docker образ для registry
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Тегирование образа...$(COLOR_RESET)"
@$(DOCKER) tag $(IMAGE_NAME):$(TAG) $(REGISTRY)/$(IMAGE_NAME):$(TAG) || \
(echo "$(COLOR_RED)✗ Ошибка тегирования образа$(COLOR_RESET)" && exit 1)
@echo "$(COLOR_GREEN)✓ Образ помечен: $(REGISTRY)/$(IMAGE_NAME):$(TAG)$(COLOR_RESET)"
docker-push: docker-tag ## 📤 Собрать, тегировать и отправить образ в registry
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Отправка образа в registry...$(COLOR_RESET)"
@$(DOCKER) push $(REGISTRY)/$(IMAGE_NAME):$(TAG) || \
(echo "$(COLOR_RED)✗ Ошибка отправки образа в registry$(COLOR_RESET)" && exit 1)
@echo "$(COLOR_GREEN)✓ Образ отправлен: $(REGISTRY)/$(IMAGE_NAME):$(TAG)$(COLOR_RESET)"
docker-run: docker-build ## 🏃 Запустить контейнер
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Запуск контейнера...$(COLOR_RESET)"
@if [ ! -f $(ENV_FILE) ]; then \
echo "$(COLOR_RED)✗ Файл .env не найден$(COLOR_RESET)"; \
echo "$(COLOR_YELLOW)Создайте файл .env на основе env.example$(COLOR_RESET)"; \
exit 1; \
fi
@$(DOCKER) run -d \
--name $(APP_NAME) \
-p 8000:8000 \
--env-file $(ENV_FILE) \
-v $$(pwd)/app:/app/app \
-v $$(pwd)/config:/app/config \
-v $$(pwd)/templates:/app/templates \
$(IMAGE_NAME):$(TAG) || \
(echo "$(COLOR_RED)✗ Ошибка запуска контейнера$(COLOR_RESET)" && exit 1)
@echo "$(COLOR_GREEN)✓ Контейнер запущен$(COLOR_RESET)"
docker-stop: ## 🛑 Остановить контейнер
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Остановка контейнера...$(COLOR_RESET)"
@$(DOCKER) stop $(APP_NAME) || echo "$(COLOR_YELLOW)⚠ Контейнер не запущен$(COLOR_RESET)"
@$(DOCKER) rm $(APP_NAME) || echo "$(COLOR_YELLOW)⚠ Контейнер не существует$(COLOR_RESET)"
@echo "$(COLOR_GREEN)✓ Контейнер остановлен$(COLOR_RESET)"
docker-logs: ## 📋 Показать логи контейнера
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Просмотр логов контейнера...$(COLOR_RESET)"
@clear || true
@$(DOCKER) logs -f --tail=50 $(APP_NAME)
docker-shell: ## 🐚 Открыть shell в контейнере
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Открытие shell в контейнере...$(COLOR_RESET)"
@clear || true
@$(DOCKER) exec -it $(APP_NAME) /bin/bash
# ============================================================================
# Git команды (подкоманды через make git CMD=<cmd>)
# ============================================================================
git: ## 📦 Git команды (используйте: make git CMD=status|pull|push|add|commit|branch)
@if [ -z "$(CMD)" ]; then \
echo "$(COLOR_BOLD)$(COLOR_CYAN)Git команды:$(COLOR_RESET)"; \
echo ""; \
echo " $(COLOR_GREEN)make git status$(COLOR_RESET) - Показать статус репозитория"; \
echo " $(COLOR_GREEN)make git pull$(COLOR_RESET) - Получить изменения"; \
echo " $(COLOR_GREEN)make git push$(COLOR_RESET) - Отправить изменения"; \
echo " $(COLOR_GREEN)make git add [file]$(COLOR_RESET) - Добавить файлы"; \
echo " $(COLOR_GREEN)make git commit [msg]$(COLOR_RESET) - Создать коммит"; \
echo " $(COLOR_GREEN)make git branch$(COLOR_RESET) - Показать список веток"; \
echo ""; \
echo "$(COLOR_YELLOW)Использование:$(COLOR_RESET)"; \
echo " $(COLOR_CYAN)make git CMD=status$(COLOR_RESET)"; \
echo " $(COLOR_CYAN)make git CMD=push ARGS=origin main$(COLOR_RESET)"; \
echo " $(COLOR_CYAN)make git-status$(COLOR_RESET) (прямой вызов)"; \
else \
$(MAKE) git-$(CMD) ARGS="$(ARGS)"; \
fi
git-status: ## 📊 Показать статус репозитория
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Статус репозитория:$(COLOR_RESET)"
@$(GIT) status
git-pull: ## ⬇️ Получить изменения из удаленного репозитория
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Получение изменений из удаленного репозитория...$(COLOR_RESET)"
@if [ -z "$(ARGS)" ]; then \
$(GIT) pull || (echo "$(COLOR_RED)✗ Ошибка получения изменений$(COLOR_RESET)" && exit 1); \
else \
$(GIT) pull $(ARGS) || (echo "$(COLOR_RED)✗ Ошибка получения изменений$(COLOR_RESET)" && exit 1); \
fi
@echo "$(COLOR_GREEN)✓ Изменения получены$(COLOR_RESET)"
git-push: ## ⬆️ Отправить изменения в удаленный репозиторий
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Отправка изменений в удаленный репозиторий...$(COLOR_RESET)"
@if [ -z "$(ARGS)" ]; then \
$(GIT) push || (echo "$(COLOR_RED)✗ Ошибка отправки изменений$(COLOR_RESET)" && exit 1); \
else \
$(GIT) push $(ARGS) || (echo "$(COLOR_RED)✗ Ошибка отправки изменений$(COLOR_RESET)" && exit 1); \
fi
@echo "$(COLOR_GREEN)✓ Изменения отправлены$(COLOR_RESET)"
git-add: ## Добавить файлы в индекс
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Добавление файлов в индекс...$(COLOR_RESET)"
@if [ -z "$(ARGS)" ]; then \
echo "$(COLOR_YELLOW)⚠ Укажите файлы для добавления: make git add ARGS=<файлы>$(COLOR_RESET)"; \
echo "$(COLOR_YELLOW)Или используйте: make git-add ARGS=<файлы>$(COLOR_RESET)"; \
exit 1; \
fi
@$(GIT) add $(ARGS)
@echo "$(COLOR_GREEN)✓ Файлы добавлены в индекс$(COLOR_RESET)"
git-commit: ## 💾 Создать коммит
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Создание коммита...$(COLOR_RESET)"
@if [ -z "$(ARGS)" ]; then \
echo "$(COLOR_YELLOW)⚠ Укажите сообщение коммита: make git commit ARGS=\"<сообщение>\"$(COLOR_RESET)"; \
echo "$(COLOR_YELLOW)Или используйте: make git-commit ARGS=\"<сообщение>\"$(COLOR_RESET)"; \
exit 1; \
fi
@$(GIT) commit -m "$(ARGS)"
@echo "$(COLOR_GREEN)✓ Коммит создан$(COLOR_RESET)"
git-branch: ## 🌿 Показать список веток
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Список веток:$(COLOR_RESET)"
@$(GIT) branch -a
# ============================================================================
# Kubernetes команды (подкоманды через make k8s CMD=<cmd>)
# ============================================================================
k8s: ## ☸️ Kubernetes команды (используйте: make k8s CMD=apply|delete|status|logs|shell|contexts|set-context)
@if [ -z "$(CMD)" ]; then \
echo "$(COLOR_BOLD)$(COLOR_CYAN)Kubernetes команды:$(COLOR_RESET)"; \
echo ""; \
echo " $(COLOR_GREEN)make k8s contexts$(COLOR_RESET) - Показать доступные контексты"; \
echo " $(COLOR_GREEN)make k8s set-context [name]$(COLOR_RESET) - Установить контекст"; \
echo " $(COLOR_GREEN)make k8s apply$(COLOR_RESET) - Применить манифесты"; \
echo " $(COLOR_GREEN)make k8s delete$(COLOR_RESET) - Удалить ресурсы"; \
echo " $(COLOR_GREEN)make k8s status$(COLOR_RESET) - Показать статус подов"; \
echo " $(COLOR_GREEN)make k8s logs$(COLOR_RESET) - Показать логи подов"; \
echo " $(COLOR_GREEN)make k8s shell$(COLOR_RESET) - Открыть shell в поде"; \
echo ""; \
if [ -n "$(K8S_CONTEXT)" ]; then \
echo "$(COLOR_CYAN)Текущий контекст: $(COLOR_BOLD)$(K8S_CONTEXT)$(COLOR_RESET)"; \
else \
echo "$(COLOR_YELLOW)⚠ Контекст не установлен$(COLOR_RESET)"; \
fi; \
echo ""; \
echo "$(COLOR_YELLOW)Использование:$(COLOR_RESET)"; \
echo " $(COLOR_CYAN)make k8s CMD=apply$(COLOR_RESET)"; \
echo " $(COLOR_CYAN)make k8s CMD=apply CONTEXT=<контекст>$(COLOR_RESET)"; \
echo " $(COLOR_CYAN)make k8s-apply$(COLOR_RESET) (прямой вызов)"; \
else \
$(MAKE) k8s-$(CMD) CONTEXT="$(CONTEXT)"; \
fi
k8s-contexts: ## 📋 Показать доступные контексты Kubernetes
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Доступные контексты Kubernetes:$(COLOR_RESET)"
@$(KUBECTL) config get-contexts || echo "$(COLOR_YELLOW)⚠ kubectl не установлен или не настроен$(COLOR_RESET)"
@if [ -n "$(K8S_CONTEXT)" ]; then \
echo ""; \
echo "$(COLOR_CYAN)Текущий контекст: $(COLOR_BOLD)$(K8S_CONTEXT)$(COLOR_RESET)"; \
fi
k8s-set-context: ## 🔄 Установить контекст Kubernetes
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Установка контекста Kubernetes...$(COLOR_RESET)"
@if [ -z "$(CONTEXT)" ]; then \
echo "$(COLOR_YELLOW)⚠ Укажите контекст: make k8s set-context CONTEXT=<имя>$(COLOR_RESET)"; \
echo "$(COLOR_YELLOW)Или используйте: make k8s-set-context CONTEXT=<имя>$(COLOR_RESET)"; \
echo ""; \
echo "$(COLOR_CYAN)Доступные контексты:$(COLOR_RESET)"; \
$(KUBECTL) config get-contexts -o name || echo "$(COLOR_YELLOW)⚠ kubectl не установлен$(COLOR_RESET)"; \
exit 1; \
fi
@$(KUBECTL) config use-context $(CONTEXT) || \
(echo "$(COLOR_RED)✗ Ошибка установки контекста$(COLOR_RESET)" && exit 1)
@echo "$(COLOR_GREEN)✓ Контекст установлен: $(CONTEXT)$(COLOR_RESET)"
k8s-apply: ## ✅ Применить манифесты Kubernetes
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Применение манифестов Kubernetes...$(COLOR_RESET)"
@if [ -n "$(CONTEXT)" ]; then \
echo "$(COLOR_CYAN)Использование контекста: $(CONTEXT)$(COLOR_RESET)"; \
$(KUBECTL) --context=$(CONTEXT) apply -f $(K8S_MANIFEST) || \
(echo "$(COLOR_RED)✗ Ошибка применения манифестов$(COLOR_RESET)" && exit 1); \
else \
if [ -n "$(K8S_CONTEXT)" ]; then \
echo "$(COLOR_CYAN)Использование текущего контекста: $(K8S_CONTEXT)$(COLOR_RESET)"; \
fi; \
$(KUBECTL) apply -f $(K8S_MANIFEST) || \
(echo "$(COLOR_RED)✗ Ошибка применения манифестов$(COLOR_RESET)" && exit 1); \
fi
@echo "$(COLOR_GREEN)✓ Манифесты применены$(COLOR_RESET)"
k8s-delete: ## 🗑️ Удалить ресурсы Kubernetes
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Удаление ресурсов Kubernetes...$(COLOR_RESET)"
@if [ -n "$(CONTEXT)" ]; then \
echo "$(COLOR_CYAN)Использование контекста: $(CONTEXT)$(COLOR_RESET)"; \
$(KUBECTL) --context=$(CONTEXT) delete -f $(K8S_MANIFEST) || \
(echo "$(COLOR_YELLOW)⚠ Ресурсы не найдены или уже удалены$(COLOR_RESET)"); \
else \
if [ -n "$(K8S_CONTEXT)" ]; then \
echo "$(COLOR_CYAN)Использование текущего контекста: $(K8S_CONTEXT)$(COLOR_RESET)"; \
fi; \
$(KUBECTL) delete -f $(K8S_MANIFEST) || \
(echo "$(COLOR_YELLOW)⚠ Ресурсы не найдены или уже удалены$(COLOR_RESET)"); \
fi
@echo "$(COLOR_GREEN)✓ Ресурсы удалены$(COLOR_RESET)"
k8s-status: ## 📊 Показать статус подов Kubernetes
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Статус подов Kubernetes:$(COLOR_RESET)"
@if [ -n "$(CONTEXT)" ]; then \
$(KUBECTL) --context=$(CONTEXT) get pods -n $(K8S_NAMESPACE) || \
(echo "$(COLOR_YELLOW)⚠ Поды не найдены$(COLOR_RESET)"); \
else \
$(KUBECTL) get pods -n $(K8S_NAMESPACE) || \
(echo "$(COLOR_YELLOW)⚠ Поды не найдены$(COLOR_RESET)"); \
fi
k8s-logs: ## 📋 Показать логи подов Kubernetes
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Просмотр логов подов Kubernetes...$(COLOR_RESET)"
@if [ -n "$(CONTEXT)" ]; then \
POD=$$($(KUBECTL) --context=$(CONTEXT) get pods -n $(K8S_NAMESPACE) -l app=$(APP_NAME) -o jsonpath='{.items[0].metadata.name}' 2>/dev/null); \
else \
POD=$$($(KUBECTL) get pods -n $(K8S_NAMESPACE) -l app=$(APP_NAME) -o jsonpath='{.items[0].metadata.name}' 2>/dev/null); \
fi; \
if [ -z "$$POD" ]; then \
echo "$(COLOR_YELLOW)⚠ Поды не найдены$(COLOR_RESET)"; \
exit 1; \
fi; \
echo "$(COLOR_CYAN)Просмотр логов пода: $$POD$(COLOR_RESET)"; \
if [ -n "$(CONTEXT)" ]; then \
$(KUBECTL) --context=$(CONTEXT) logs -f -n $(K8S_NAMESPACE) $$POD --tail=50; \
else \
$(KUBECTL) logs -f -n $(K8S_NAMESPACE) $$POD --tail=50; \
fi
k8s-shell: ## 🐚 Открыть shell в поде Kubernetes
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Открытие shell в поде Kubernetes...$(COLOR_RESET)"
@if [ -n "$(CONTEXT)" ]; then \
POD=$$($(KUBECTL) --context=$(CONTEXT) get pods -n $(K8S_NAMESPACE) -l app=$(APP_NAME) -o jsonpath='{.items[0].metadata.name}' 2>/dev/null); \
else \
POD=$$($(KUBECTL) get pods -n $(K8S_NAMESPACE) -l app=$(APP_NAME) -o jsonpath='{.items[0].metadata.name}' 2>/dev/null); \
fi; \
if [ -z "$$POD" ]; then \
echo "$(COLOR_YELLOW)⚠ Поды не найдены$(COLOR_RESET)"; \
exit 1; \
fi; \
echo "$(COLOR_CYAN)Открытие shell в поде: $$POD$(COLOR_RESET)"; \
clear || true; \
if [ -n "$(CONTEXT)" ]; then \
$(KUBECTL) --context=$(CONTEXT) exec -it -n $(K8S_NAMESPACE) $$POD -- /bin/bash || \
$(KUBECTL) --context=$(CONTEXT) exec -it -n $(K8S_NAMESPACE) $$POD -- /bin/sh; \
else \
$(KUBECTL) exec -it -n $(K8S_NAMESPACE) $$POD -- /bin/bash || \
$(KUBECTL) exec -it -n $(K8S_NAMESPACE) $$POD -- /bin/sh; \
fi
# ============================================================================
# Env команды (подкоманды через make env CMD=<cmd>)
# ============================================================================
env: ## 🔐 Env команды (используйте: make env CMD=check|create|create-force)
@if [ -z "$(CMD)" ]; then \
echo "$(COLOR_BOLD)$(COLOR_CYAN)Env команды:$(COLOR_RESET)"; \
echo ""; \
echo " $(COLOR_GREEN)make env check$(COLOR_RESET) - Проверить наличие .env файла"; \
echo " $(COLOR_GREEN)make env create$(COLOR_RESET) - Создать .env файл из env.example"; \
echo " $(COLOR_GREEN)make env create-force$(COLOR_RESET) - Принудительно создать .env файл"; \
echo ""; \
echo "$(COLOR_YELLOW)Использование:$(COLOR_RESET)"; \
echo " $(COLOR_CYAN)make env CMD=check$(COLOR_RESET)"; \
echo " $(COLOR_CYAN)make env-check$(COLOR_RESET) (прямой вызов)"; \
else \
$(MAKE) env-$(CMD); \
fi
env-check: ## 🔍 Проверить наличие .env файла
@if [ -f $(ENV_FILE) ]; then \
echo "$(COLOR_GREEN)✓ Файл .env найден$(COLOR_RESET)"; \
else \
echo "$(COLOR_RED)✗ Файл .env не найден$(COLOR_RESET)"; \
echo "$(COLOR_YELLOW)Создайте файл .env на основе env.example$(COLOR_RESET)"; \
echo "$(COLOR_CYAN)Используйте: make env create$(COLOR_RESET)"; \
exit 1; \
fi
env-create: ## 📝 Создать .env файл из env.example
@if [ -f $(ENV_FILE) ]; then \
echo "$(COLOR_YELLOW)⚠ Файл .env уже существует$(COLOR_RESET)"; \
echo "$(COLOR_YELLOW)⚠ Используйте 'make env create-force' для перезаписи$(COLOR_RESET)"; \
exit 1; \
fi
@cp env.example $(ENV_FILE)
@echo "$(COLOR_GREEN)✓ Файл .env создан из env.example$(COLOR_RESET)"
@echo "$(COLOR_YELLOW)Не забудьте отредактировать .env и указать необходимые значения$(COLOR_RESET)"
env-create-force: ## 📝 Принудительно создать .env файл из env.example (перезаписать существующий)
@cp env.example $(ENV_FILE)
@echo "$(COLOR_GREEN)✓ Файл .env создан из env.example (перезаписан)$(COLOR_RESET)"
@echo "$(COLOR_YELLOW)Не забудьте отредактировать .env и указать необходимые значения$(COLOR_RESET)"
# ============================================================================
# Утилиты
# ============================================================================
version: ## 📌 Показать версию проекта
@echo "$(COLOR_BOLD)$(COLOR_CYAN)Версия проекта: $(COLOR_RESET)$(COLOR_GREEN)$(VERSION)$(COLOR_RESET)"
@echo "$(COLOR_BOLD)$(COLOR_CYAN)Тег образа: $(COLOR_RESET)$(COLOR_GREEN)$(TAG)$(COLOR_RESET)"
@echo "$(COLOR_BOLD)$(COLOR_CYAN)Регистр: $(COLOR_RESET)$(COLOR_GREEN)$(REGISTRY)$(COLOR_RESET)"
psql: ## 🗄️ Подключиться к PostgreSQL (если используется)
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Подключение к PostgreSQL...$(COLOR_RESET)"
@$(DOCKER_COMPOSE) exec postgres psql -U postgres || echo "$(COLOR_YELLOW)⚠ PostgreSQL не запущен$(COLOR_RESET)"
redis-cli: ## 🔴 Подключиться к Redis (если используется)
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Подключение к Redis...$(COLOR_RESET)"
@$(DOCKER_COMPOSE) exec redis redis-cli || echo "$(COLOR_YELLOW)⚠ Redis не запущен$(COLOR_RESET)"
# ============================================================================
# Целевые команды по умолчанию
# ============================================================================
.DEFAULT_GOAL := help

642
README.md Normal file
View File

@@ -0,0 +1,642 @@
# Message Gateway
Приложение для отправки мониторинговых алертов из Grafana, Zabbix и AlertManager в мессенджеры (Telegram, MAX/VK) и создания тикетов в Jira.
**Автор:** Сергей Антропов
**Сайт:** https://devops.org.ru
## Описание
Message Gateway - это микросервис, который принимает webhooks от систем мониторинга (Grafana, Zabbix, AlertManager) и отправляет уведомления в различные мессенджеры (Telegram, MAX/VK). Также поддерживает автоматическое создание тикетов в Jira на основе алертов. Поддерживает фильтрацию по стоп-словам, форматирование сообщений через Jinja2 шаблоны и интеграцию с Prometheus для сбора метрик.
### Поддержка мессенджеров
- **Telegram** - Полная поддержка текстовых сообщений, медиа (фото, видео, аудио, документы) и тредов
- **MAX/VK** - Поддержка текстовых сообщений и медиа (треды не поддерживаются)
## Документация
Подробная документация разделена на отдельные файлы в папке [`docs/`](docs/):
### Основная документация
- 📖 [Настройка ботов](docs/bots.md) - как создать ботов в Telegram и MAX/VK
- 📖 [Конфигурация групп](docs/groups.md) - как настроить группы мессенджеров
- 📖 [Шаблоны сообщений](docs/templates.md) - как работают шаблоны Jinja2
- 📖 [Конфигурация Jira Mapping](docs/jira-mapping.md) - как настроить маппинг алертов в Jira
- 📖 [Отправка сообщений](docs/messaging.md) - как работает отправка сообщений
### Настройка систем мониторинга
- 📖 [Настройка Grafana](docs/monitoring/grafana.md) - как настроить Grafana для отправки алертов
- 📖 [Настройка Zabbix](docs/monitoring/zabbix.md) - как настроить Zabbix для отправки алертов
- 📖 [Настройка AlertManager](docs/monitoring/alertmanager.md) - как настроить AlertManager для отправки алертов
### API документация
- 📖 [API для управления группами](docs/api/groups.md) - управление группами мессенджеров через API
- 📖 [API для отправки сообщений](docs/api/message.md) - отправка простых сообщений в мессенджеры
- 📖 [API для проверки здоровья](docs/api/health.md) - проверка здоровья и готовности приложения
- 📖 [API для отладки](docs/api/debug.md) - сохранение JSON данных для отладки
- 📖 [Декораторы API](docs/api/decorators.md) - декораторы для авторизации и скрытия эндпоинтов
## Возможности
- ✅ Отправка алертов из Grafana в мессенджеры (Telegram, MAX/VK)
- ✅ Отправка алертов из Zabbix в мессенджеры (Telegram, MAX/VK)
- ✅ Отправка алертов из AlertManager в мессенджеры (Telegram, MAX/VK)
- ✅ Поддержка отправки в треды Telegram (не поддерживается для MAX/VK)
-**Поддержка нескольких мессенджеров (Telegram, MAX/VK)**
-**Отправка простых текстовых сообщений**
-**Отправка фото, видео, аудио и документов**
-**Управление группами мессенджеров (CRUD)**
-**Защита управления группами API ключом**
-**Автоматическое создание тикетов в Jira**
-**Настройка маппинга алертов в Jira тикеты**
- ✅ Фильтрация по стоп-словам
- ✅ Форматирование сообщений через Jinja2 шаблоны
- ✅ Метрики Prometheus
- ✅ Интеграция с OpenTelemetry
- ✅ Кэширование конфигурации групп
- ✅ Асинхронная обработка запросов
- ✅ Валидация данных через Pydantic
- ✅ Подробное логирование
## Требования
- Python 3.11+
- Docker и Docker Compose
- Telegram Bot Token (для Telegram)
- MAX/VK Access Token (для MAX/VK, опционально)
- Конфигурация групп мессенджеров (config/groups.json)
- Пароль для управления группами (опционально, для CRUD операций)
- Jira API Token (опционально, для интеграции с Jira)
## Установка
### 1. Клонирование репозитория
```bash
git clone <repository-url>
cd message-gateway
```
### 2. Настройка переменных окружения
Создайте файл `.env` на основе `.env.example`:
```bash
cp env.example .env
```
Отредактируйте `.env` и укажите необходимые переменные:
```env
# Telegram настройки
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
TELEGRAM_ENABLED=true
# MAX/VK настройки (опционально)
MAX_ACCESS_TOKEN=your_max_access_token_here
MAX_API_VERSION=5.131
MAX_ENABLED=false
# Общие настройки мессенджеров
DEFAULT_MESSENGER=telegram
# Настройки мониторинга
GRAFANA_URL=http://grafana.example.com
ZABBIX_URL=https://zabbix.example.com
# Jira настройки (опционально)
JIRA_ENABLED=false
JIRA_URL=https://jira.example.com
JIRA_EMAIL=user@example.com
JIRA_API_TOKEN=your_jira_api_token_here
JIRA_PROJECT_KEY=MON
JIRA_DEFAULT_ASSIGNEE=user@example.com
JIRA_DEFAULT_ISSUE_TYPE=Bug
JIRA_CREATE_ON_ALERT=true
JIRA_CREATE_ON_RESOLVED=false
```
### 3. Настройка конфигурации групп
Отредактируйте `config/groups.json` и добавьте группы мессенджеров.
> 📖 **Подробная документация:** [Конфигурация групп](docs/groups.md)
```json
{
"monitoring": {
"messenger": "telegram",
"chat_id": -1001234567890,
"thread_id": 0,
"config": {}
},
"alerts": {
"messenger": "telegram",
"chat_id": -1001234567891,
"thread_id": 0,
"config": {}
},
"max_alerts": {
"messenger": "max",
"chat_id": "123456789",
"thread_id": null,
"config": {
"access_token": "optional_override_max_token"
}
}
}
```
**Примечания:**
- `messenger` - тип мессенджера (`telegram`, `max`) - обязательное поле
- `chat_id` - ID чата (может быть числом для Telegram или строкой для MAX/VK) - обязательное поле
- `thread_id` - ID треда в группе (только для Telegram, `0` для основной группы, `null` для MAX/VK)
- `config` - дополнительная конфигурация для мессенджера (например, `access_token` для MAX/VK)
### 4. Настройка конфигурации маппинга Jira
Отредактируйте `config/jira_mapping.json` и настройте маппинг алертов в Jira тикеты.
> 📖 **Подробная документация:** [Конфигурация Jira Mapping](docs/jira-mapping.md)
```json
{
"alertmanager": {
"default_project": "MON",
"default_assignee": null,
"default_issue_type": "Bug",
"default_priority": "High",
"mappings": [
{
"conditions": {
"severity": "critical",
"namespace": "production"
},
"project": "MON",
"assignee": null,
"issue_type": "Bug",
"priority": "Highest",
"labels": ["critical", "production", "alertmanager"]
}
]
}
}
```
### 5. Запуск приложения
#### Использование Docker Compose
```bash
# Первый запуск (сборка и запуск)
make up-build
# Последующие запуски
make up
# Или напрямую через docker-compose
docker-compose up -d
```
#### Использование Makefile
```bash
make help # Показать справку по всем командам
make build # Собрать Docker образ
make up # Запустить приложение
make up-build # Собрать и запустить приложение (рекомендуется для первого запуска)
make logs # Показать логи
make shell # Открыть shell в контейнере
make down # Остановить приложение
make status # Показать статус контейнеров
make health # Проверить здоровье приложения
make ready # Проверить готовность приложения
```
> **Примечание:** Все команды выполняются через Makefile. Скрипты `.sh` больше не используются и были удалены.
>
> **Подкоманды:** Для удобства использования подкоманд (docker, git, k8s, env) доступны три варианта:
> 1. Через переменную CMD: `make docker CMD=build`
> 2. Напрямую: `make docker-build`
> 3. Через обертку: `./make-wrapper.sh docker build` (для синтаксиса с пробелами)
Доступные команды:
**Основные команды:**
- `make build` - Собрать Docker образ
- `make up` - Запустить приложение
- `make up-build` - Собрать и запустить приложение (рекомендуется для первого запуска)
- `make down` - Остановить приложение
- `make restart` - Перезапустить приложение
- `make logs` - Показать логи приложения
- `make shell` - Открыть shell в контейнере
- `make stop` - Остановить приложение (alias для down)
- `make status` - Показать статус контейнеров
- `make health` - Проверить здоровье приложения
- `make ready` - Проверить готовность приложения
**Разработка:**
- `make dev` - Запустить в режиме разработки
- `make test` - Запустить тесты
- `make lint` - Запустить линтер
- `make format` - Форматировать код
- `make install` - Установить зависимости
- `make clean` - Очистить Docker образы и контейнеры
- `make clean-all` - Очистить все Docker ресурсы (включая volumes)
**Docker команды:**
- `make docker CMD=build` или `make docker-build` - Собрать Docker образ (без docker-compose)
- `make docker CMD=tag` или `make docker-tag` - Тегировать Docker образ для registry
- `make docker CMD=push` или `make docker-push` - Собрать, тегировать и отправить образ в registry
- `make docker CMD=run` или `make docker-run` - Запустить контейнер (без docker-compose)
- `make docker CMD=stop` или `make docker-stop` - Остановить контейнер (без docker-compose)
- `make docker CMD=logs` или `make docker-logs` - Показать логи контейнера (без docker-compose)
- `make docker CMD=shell` или `make docker-shell` - Открыть shell в контейнере (без docker-compose)
- `./make-wrapper.sh docker build` - Альтернативный синтаксис через обертку
**Git команды:**
- `make git CMD=status` или `make git-status` - Показать статус репозитория
- `make git CMD=pull` или `make git-pull` - Получить изменения из удаленного репозитория
- `make git CMD=push ARGS=origin main` или `make git-push` - Отправить изменения в удаленный репозиторий
- `make git CMD=add ARGS=file1 file2` или `make git-add ARGS=file1 file2` - Добавить файлы в индекс
- `make git CMD=commit ARGS="message"` или `make git-commit ARGS="message"` - Создать коммит
- `make git CMD=branch` или `make git-branch` - Показать список веток
- `./make-wrapper.sh git status` - Альтернативный синтаксис через обертку
- `./make-wrapper.sh git push origin main` - Отправить изменения с аргументами
**Kubernetes команды:**
- `make k8s CMD=contexts` или `make k8s-contexts` - Показать доступные контексты
- `make k8s CMD=set-context CONTEXT=prod` или `make k8s-set-context CONTEXT=prod` - Установить контекст
- `make k8s CMD=apply` или `make k8s-apply` - Применить манифесты
- `make k8s CMD=apply CONTEXT=prod` - Применить манифесты с указанием контекста
- `make k8s CMD=delete` или `make k8s-delete` - Удалить ресурсы
- `make k8s CMD=status` или `make k8s-status` - Показать статус подов
- `make k8s CMD=logs` или `make k8s-logs` - Показать логи подов
- `make k8s CMD=shell` или `make k8s-shell` - Открыть shell в поде
- `./make-wrapper.sh k8s apply CONTEXT=prod` - Альтернативный синтаксис через обертку
**Env команды:**
- `make env CMD=check` или `make env-check` - Проверить наличие .env файла
- `make env CMD=create` или `make env-create` - Создать .env файл из env.example
- `make env CMD=create-force` или `make env-create-force` - Принудительно создать .env файл (перезаписать существующий)
- `./make-wrapper.sh env check` - Альтернативный синтаксис через обертку
**Утилиты:**
- `make version` - Показать версию проекта
- `make psql` - Подключиться к PostgreSQL (если используется)
- `make redis-cli` - Подключиться к Redis (если используется)
## Использование
### API Endpoints
> 📖 **Подробная документация API:**
> - [API для управления группами](docs/api/groups.md)
> - [API для отправки сообщений](docs/api/message.md)
> - [API для проверки здоровья](docs/api/health.md)
> - [API для отладки](docs/api/debug.md)
#### Мониторинг (без авторизации)
Все эндпоинты мониторинга объединены под тегом `monitoring` и **не требуют авторизации**:
- `POST /api/v1/grafana/{group_name}/{thread_id}` - Отправка алерта из Grafana
- `POST /api/v1/zabbix/{group_name}/{thread_id}` - Отправка алерта из Zabbix
- `POST /api/v1/alertmanager/{k8s_cluster}/{group_name}/{thread_id}` - Отправка алерта из AlertManager
> 📖 **Подробная документация:**
> - [Настройка Grafana](docs/monitoring/grafana.md)
> - [Настройка Zabbix](docs/monitoring/zabbix.md)
> - [Настройка AlertManager](docs/monitoring/alertmanager.md)
**Примечание:** Эндпоинты мониторинга объединены в один файл `app/api/v1/endpoints/monitoring.py` и используют общий тег `monitoring` в Swagger UI.
#### Управление группами (требуется API ключ)
- `GET /api/v1/groups/messengers` - Получить список поддерживаемых мессенджеров
- `GET /api/v1/groups` - Получить список групп (без API ключа - только названия, с API ключом - полная информация)
- `POST /api/v1/groups` - Создать группу
- `PUT /api/v1/groups/{group_name}` - Обновить группу
- `DELETE /api/v1/groups/{group_name}` - Удалить группу
> 📖 **Подробная документация:** [API для управления группами](docs/api/groups.md)
#### Отправка сообщений (требуется API ключ)
Все эндпоинты для отправки сообщений защищены декоратором `@require_api_key` и **требуют API ключ** в заголовке `X-API-Key`:
- `POST /api/v1/message/text` - Отправить текстовое сообщение
- `POST /api/v1/message/photo` - Отправить фото
- `POST /api/v1/message/video` - Отправить видео
- `POST /api/v1/message/audio` - Отправить аудио
- `POST /api/v1/message/document` - Отправить документ
**Аутентификация:**
- Все эндпоинты требуют заголовок `X-API-Key: your_api_key_here`
- API ключ настраивается в переменной окружения `API_KEY` в файле `.env`
- В Swagger UI доступна авторизация через кнопку "Authorize" (🔒)
> 📖 **Подробная документация:** [API для отправки сообщений](docs/api/message.md)
#### Проверка здоровья (без авторизации)
- `GET /api/v1/health` - Проверка здоровья и готовности приложения
> 📖 **Подробная документация:** [API для проверки здоровья](docs/api/health.md)
#### Отладка
- `POST /api/v1/debug/dump` - Сохранить JSON данные для отладки
> 📖 **Подробная документация:** [API для отладки](docs/api/debug.md)
#### Jira
**Примечание:** Создание тикетов в Jira происходит автоматически при получении алертов (внутренний процесс). API endpoints для создания тикетов вручную удалены в соответствии с требованиями.
> 📖 **Подробная документация:** [Конфигурация Jira Mapping](docs/jira-mapping.md)
### Настройка Webhooks
> 📖 **Подробная документация:**
> - [Настройка Grafana](docs/monitoring/grafana.md)
> - [Настройка Zabbix](docs/monitoring/zabbix.md)
> - [Настройка AlertManager](docs/monitoring/alertmanager.md)
## Интеграция с Jira
### Настройка Jira
> 📖 **Подробная документация:** [Конфигурация Jira Mapping](docs/jira-mapping.md)
**Примечание:** Создание тикетов в Jira происходит автоматически при получении алертов (внутренний процесс). API endpoints для создания тикетов вручную удалены в соответствии с требованиями.
## Стоп-слова
Приложение поддерживает фильтрацию алертов по стоп-словам. Алерты, содержащие следующие паттерны, не будут отправлены:
- `InfoInhibitor`
- `Watchdog`
- `etcdHighCommitDurations`
- `etcdHighNumberOfFailedGRPCRequests`
- `kubePersistentVolumeFillingUp`
- `kubePersistentVolumeInodesFillingUp`
## Метрики Prometheus
Приложение отправляет метрики в Prometheus Pushgateway:
- `tg_monitoring_gateway_api_endpoint_total` - общее количество обращений к эндпоинтам API
- `tg_monitoring_gateway_total_messages` - всего сообщений получено
- `tg_monitoring_gateway_sent_messages` - сообщений успешно отправлено
- `tg_monitoring_gateway_reject_messages` - сообщений отклонено (стоп-слова)
- `tg_monitoring_gateway_error_messages` - ошибок отправки сообщений
- `tg_monitoring_gateway_firing_messages` - горящих алертов
- `tg_monitoring_gateway_critical_messages` - критических алертов
- `tg_monitoring_gateway_resolved_messages` - исправленных алертов
- `tg_monitoring_gateway_jira_tickets_created` - Jira тикетов создано
- `tg_monitoring_gateway_jira_tickets_errors` - ошибок создания Jira тикетов
## Отправка простых сообщений
> 📖 **Подробная документация:** [API для отправки сообщений](docs/api/message.md)
Приложение поддерживает отправку простых сообщений (текст, фото, видео, аудио, документы) в мессенджеры (Telegram, MAX/VK) без привязки к системам мониторинга.
## Разработка
### Запуск в режиме разработки
```bash
make dev
```
### Запуск тестов
```bash
make test
```
### Линтинг
```bash
make lint
```
### Форматирование кода
```bash
make format
```
### Просмотр всех доступных команд
```bash
make help
```
### Проверка здоровья приложения
```bash
make health # Проверить здоровье
make ready # Проверить готовность
```
### Управление Docker образами
```bash
# Использование через переменную CMD
make docker CMD=build # Собрать образ
make docker CMD=tag # Тегировать образ
make docker CMD=push # Отправить образ в registry
# Или напрямую
make docker-build # Собрать образ
make docker-tag # Тегировать образ
make docker-push # Отправить образ в registry
# Или через обертку
./make-wrapper.sh docker build
./make-wrapper.sh docker push
```
### Работа с Git
```bash
# Использование через переменную CMD
make git CMD=status # Показать статус
make git CMD=pull # Получить изменения
make git CMD=push ARGS=origin main # Отправить изменения
make git CMD=add ARGS=file1 file2 # Добавить файлы
make git CMD=commit ARGS="message" # Создать коммит
# Или напрямую
make git-status
make git-pull
make git-push
# Или через обертку
./make-wrapper.sh git status
./make-wrapper.sh git push origin main
./make-wrapper.sh git commit "Update README"
```
### Работа с Kubernetes
```bash
# Показать доступные контексты
make k8s CMD=contexts
# или
make k8s-contexts
# или
./make-wrapper.sh k8s contexts
# Установить контекст
make k8s CMD=set-context CONTEXT=prod
# или
make k8s-set-context CONTEXT=prod
# или
./make-wrapper.sh k8s set-context CONTEXT=prod
# Применить манифесты
make k8s CMD=apply
# или с указанием контекста
make k8s CMD=apply CONTEXT=prod
# или
make k8s-apply
# или
./make-wrapper.sh k8s apply CONTEXT=prod
# Показать статус подов
make k8s CMD=status
# или
make k8s-status
# Показать логи подов
make k8s CMD=logs
# или
make k8s-logs
# Открыть shell в поде
make k8s CMD=shell
# или
make k8s-shell
```
### Работа с .env файлом
```bash
# Проверить наличие .env файла
make env CMD=check
# или
make env-check
# или
./make-wrapper.sh env check
# Создать .env файл из env.example
make env CMD=create
# или
make env-create
# или
./make-wrapper.sh env create
# Принудительно создать .env файл
make env CMD=create-force
# или
make env-create-force
# или
./make-wrapper.sh env create-force
```
## Развертывание в Kubernetes
1. Создайте Secret с переменными окружения:
```bash
kubectl create secret generic message-gateway-secret \
--from-literal=telegram_bot_token=your_token \
--from-literal=grafana_url=http://grafana.example.com \
--from-literal=jira_enabled=true \
--from-literal=jira_url=https://jira.example.com \
--from-literal=jira_email=user@example.com \
--from-literal=jira_api_token=your_jira_api_token \
-n message-gateway
```
2. Примените манифесты:
```bash
# Использование make
make k8s CMD=apply
# или с указанием контекста
make k8s CMD=apply CONTEXT=prod
# или напрямую
make k8s-apply
# или через обертку
./make-wrapper.sh k8s apply CONTEXT=prod
# Или напрямую через kubectl
kubectl apply -f kubernetes.yaml
```
## Структура проекта
```
message-gateway/
├── app/
│ ├── api/
│ │ └── v1/
│ │ ├── endpoints/ # Эндпоинты API
│ │ │ ├── jira.py # Эндпоинты Jira
│ │ │ ├── message.py # Эндпоинты для отправки сообщений
│ │ │ ├── groups.py # Эндпоинты для управления группами
│ │ │ └── ...
│ │ └── router.py # Роутер API
│ ├── core/ # Основные утилиты
│ │ ├── config.py # Конфигурация
│ │ ├── telegram_client.py # Клиент Telegram
│ │ ├── jira_client.py # Клиент Jira
│ │ ├── jira_mapping.py # Управление маппингом Jira
│ │ ├── jira_utils.py # Утилиты для работы с Jira
│ │ ├── groups.py # Управление группами
│ │ ├── metrics.py # Метрики Prometheus
│ │ └── utils.py # Вспомогательные утилиты
│ ├── models/ # Модели Pydantic
│ │ ├── jira.py # Модели Jira
│ │ ├── message.py # Модели для отправки сообщений
│ │ ├── group.py # Модели для управления группами
│ │ ├── grafana.py # Модели Grafana
│ │ ├── zabbix.py # Модели Zabbix
│ │ ├── alertmanager.py # Модели AlertManager
│ │ └── ...
│ ├── modules/ # Модули обработки алертов
│ └── main.py # Точка входа приложения
├── config/ # Конфигурационные файлы
│ ├── groups.json # Конфигурация групп Telegram
│ └── jira_mapping.json # Конфигурация маппинга Jira
├── templates/ # Jinja2 шаблоны
│ ├── jira_common.tmpl # Шаблон описания Jira тикета
│ └── ...
├── docker-compose.yaml # Docker Compose конфигурация
├── Dockerfile # Docker образ
├── Makefile # Makefile для управления проектом
├── make-wrapper.sh # Обертка для поддержки подкоманд с пробелами
├── kubernetes.yaml # Kubernetes манифесты
├── requirements.txt # Python зависимости
├── env.example # Пример файла окружения
└── README.md # Документация
```
## Лицензия
Apache 2.0
## Контакты
- **Автор:** Сергей Антропов
- **Email:** sergey@antropoff.ru
- **Сайт:** https://devops.org.ru

0
app/__init__.py Normal file
View File

6
app/api/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
API модули приложения.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""

6
app/api/v1/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
API версии 1.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""

View File

@@ -0,0 +1,23 @@
"""
Эндпоинты API версии 1.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from app.api.v1.endpoints import (
health,
monitoring,
debug,
jira,
message,
groups,
)
__all__ = [
"health",
"monitoring",
"debug",
"jira",
"message",
"groups",
]

View File

@@ -0,0 +1,114 @@
"""
Эндпоинты для отладки.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import json
import logging
from typing import Dict, Any
from fastapi import APIRouter, HTTPException, Body
import aiofiles
from app.core.metrics import metrics
from app.core.auth import hide_from_api
# Импортируем settings в функции, чтобы избежать циклических зависимостей
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/debug", tags=["debug"])
@hide_from_api
@router.post(
"/dump",
name="JSON Debug dump",
response_model=Dict[str, Any],
include_in_schema=False, # Скрываем эндпоинт из Swagger UI
responses={
200: {
"description": "Данные успешно сохранены",
"content": {
"application/json": {
"example": {
"status": "ok",
"message": "Данные сохранены в dump.json",
"data": {
"test": "data",
"timestamp": "2024-01-01T00:00:00Z",
"source": "grafana",
"alert": {
"title": "Test alert",
"state": "alerting"
}
}
}
}
}
},
500: {
"description": "Ошибка сохранения данных",
"content": {
"application/json": {
"example": {"detail": "Ошибка записи в файл"}
}
}
}
}
)
async def dump_request(
dump: Dict[str, Any] = Body(
...,
description="JSON данные для сохранения в файл dump.json",
examples=[
{
"test": "data",
"timestamp": "2024-01-01T00:00:00Z",
"source": "grafana",
"alert": {
"title": "Test alert",
"state": "alerting"
}
},
{
"source": "zabbix",
"event": {
"event-id": "8819711",
"event-name": "High CPU utilization",
"status": "PROBLEM"
}
},
{
"source": "alertmanager",
"status": "firing",
"commonLabels": {
"alertname": "HighCPUUsage",
"severity": "critical"
}
}
]
)
) -> Dict[str, Any]:
"""
Сохранить JSON данные в файл для отладки.
Используется для сохранения входящих webhook запросов для анализа.
Подробная документация: см. docs/api/debug.md
"""
metrics.increment_api_endpoint("debug_dump")
logger.info("Получен запрос на сохранение данных для отладки")
try:
dump_path = "/app/app/dump.json"
async with aiofiles.open(dump_path, 'w', encoding='utf-8') as f:
await f.write(json.dumps(dump, indent=4, ensure_ascii=False))
logger.info(f"Данные сохранены в {dump_path}")
return {
"status": "ok",
"message": "Данные сохранены в dump.json",
"data": dump
}
except Exception as e:
logger.error(f"Ошибка сохранения данных: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка записи в файл: {str(e)}")

View File

@@ -0,0 +1,511 @@
"""
Эндпоинты для управления группами мессенджеров.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Dict, Any, Optional
from fastapi import APIRouter, HTTPException, Query, Path, Body, Request
from app.core.metrics import metrics
from app.core.groups import groups_config
from app.core.auth import require_api_key, require_api_key_dependency, require_api_key_optional
from app.models.group import (
CreateGroupRequest,
UpdateGroupRequest,
DeleteGroupRequest,
GroupInfo
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/groups", tags=["groups"])
@router.get(
"/messengers",
name="Получить список поддерживаемых мессенджеров",
response_model=Dict[str, Any],
responses={
200: {
"description": "Список поддерживаемых мессенджеров",
"content": {
"application/json": {
"example": {
"status": "ok",
"messengers": [
{
"type": "telegram",
"name": "Telegram",
"supports_threads": True,
"enabled": True
},
{
"type": "max",
"name": "MAX/VK",
"supports_threads": False,
"enabled": False
}
]
}
}
}
}
}
)
async def get_messengers() -> Dict[str, Any]:
"""
Получить список поддерживаемых мессенджеров.
Returns:
Список поддерживаемых мессенджеров с их характеристиками.
Подробная документация: см. docs/api/groups.md
"""
from app.core.config import get_settings
settings = get_settings()
messengers = [
{
"type": "telegram",
"name": "Telegram",
"supports_threads": True,
"enabled": settings.telegram_enabled
},
{
"type": "max",
"name": "MAX/VK",
"supports_threads": False,
"enabled": settings.max_enabled
}
]
return {
"status": "ok",
"messengers": messengers
}
@router.get(
"",
name="Получить список групп",
response_model=Dict[str, Any],
responses={
200: {
"description": "Список групп успешно получен",
"content": {
"application/json": {
"examples": {
"without_api_key": {
"summary": "Без API ключа (только названия)",
"value": {
"status": "ok",
"groups": [
{"name": "monitoring", "chat_id": None},
{"name": "alerts", "chat_id": None}
],
"count": 2
}
},
"with_api_key": {
"summary": "С API ключом (полная информация)",
"value": {
"status": "ok",
"groups": [
{
"name": "monitoring",
"messenger": "telegram",
"chat_id": -1001234567890,
"thread_id": 0
},
{
"name": "alerts_max",
"messenger": "max",
"chat_id": "123456789",
"thread_id": None
}
],
"count": 2
}
}
}
}
}
},
401: {
"description": "Неверный API ключ",
"content": {
"application/json": {
"example": {"detail": "Неверный или отсутствующий API ключ"}
}
}
},
500: {
"description": "Ошибка сервера",
"content": {
"application/json": {
"example": {"detail": "Ошибка получения списка групп"}
}
}
}
}
)
async def get_groups(
request: Request,
api_key_header: Optional[bool] = require_api_key_optional
) -> Dict[str, Any]:
"""
Получить список всех групп.
Без API ключа: возвращает только названия групп без ID.
С API ключом (заголовок X-API-Key): возвращает полную информацию о группах включая ID.
Подробная документация: см. docs/api/groups.md
"""
metrics.increment_api_endpoint("groups_list")
# Если API ключ валиден, возвращаем полную информацию
include_ids = api_key_header is True
# Получаем группы
try:
groups_dict = await groups_config.get_all_groups(include_ids=include_ids)
# Формируем список групп
groups = []
for name, group_config in groups_dict.items():
if include_ids:
# Возвращаем полную информацию о группе
if isinstance(group_config, dict) and group_config is not None:
groups.append(GroupInfo(
name=name,
messenger=group_config.get("messenger"),
chat_id=group_config.get("chat_id"),
thread_id=group_config.get("thread_id")
))
else:
# Если group_config is None, значит include_ids=False, но мы здесь не должны быть
groups.append(GroupInfo(
name=name,
messenger=None,
chat_id=None,
thread_id=None
))
else:
# Возвращаем только название группы (group_config будет None)
groups.append(GroupInfo(
name=name,
messenger=None,
chat_id=None,
thread_id=None
))
return {
"status": "ok",
"groups": [group.model_dump() for group in groups],
"count": len(groups)
}
except Exception as e:
logger.error(f"Ошибка получения списка групп: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка получения списка групп: {str(e)}")
@require_api_key
@router.post(
"",
name="Создать группу",
response_model=Dict[str, Any],
dependencies=[require_api_key_dependency],
responses={
200: {
"description": "Группа успешно создана",
"content": {
"application/json": {
"example": {
"status": "ok",
"message": "Группа 'monitoring' создана с ID -1001234567890"
}
}
}
},
400: {
"description": "Ошибка запроса (группа уже существует или неверные данные)",
"content": {
"application/json": {
"examples": {
"group_exists": {
"summary": "Группа уже существует",
"value": {"detail": "Группа 'monitoring' уже существует"}
},
"invalid_data": {
"summary": "Неверные данные",
"value": {"detail": "Неверный формат данных"}
}
}
}
}
},
401: {
"description": "Ошибка авторизации (неверный API ключ)",
"content": {
"application/json": {
"example": {"detail": "Неверный или отсутствующий API ключ"}
}
}
},
500: {
"description": "Ошибка сервера",
"content": {
"application/json": {
"example": {"detail": "Ошибка создания группы"}
}
}
}
}
)
async def create_group(
request: Request,
body: CreateGroupRequest = Body(
...,
examples=[
{
"group_name": "monitoring",
"messenger": "telegram",
"chat_id": -1001234567890,
"thread_id": 0
},
{
"group_name": "max_alerts",
"messenger": "max",
"chat_id": "123456789",
"thread_id": 0,
"config": {
"access_token": "your_max_access_token"
}
}
]
)
) -> Dict[str, Any]:
"""
Создать новую группу в конфигурации.
Требуется API ключ в заголовке X-API-Key.
Подробная документация: см. docs/api/groups.md
"""
metrics.increment_api_endpoint("groups_create")
# Создаем группу
try:
success = await groups_config.create_group(
group_name=body.group_name,
chat_id=body.chat_id,
messenger=body.messenger,
thread_id=body.thread_id,
config=body.config
)
if not success:
raise HTTPException(
status_code=400,
detail=f"Группа '{body.group_name}' уже существует"
)
return {
"status": "ok",
"message": f"Группа '{body.group_name}' создана с мессенджером '{body.messenger}' и ID {body.chat_id}"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Ошибка создания группы: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка создания группы: {str(e)}")
@require_api_key
@router.put(
"/{group_name}",
name="Обновить группу",
response_model=Dict[str, Any],
dependencies=[require_api_key_dependency],
responses={
200: {
"description": "Группа успешно обновлена",
"content": {
"application/json": {
"example": {
"status": "ok",
"message": "Группа 'monitoring' обновлена с ID -1001234567891"
}
}
}
},
400: {
"description": "Ошибка запроса (группа не найдена или неверные данные)",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена"}
}
}
},
401: {
"description": "Ошибка авторизации (неверный API ключ)",
"content": {
"application/json": {
"example": {"detail": "Неверный или отсутствующий API ключ"}
}
}
},
500: {
"description": "Ошибка сервера",
"content": {
"application/json": {
"example": {"detail": "Ошибка обновления группы"}
}
}
}
}
)
async def update_group(
request: Request,
group_name: str = Path(
...,
description="Имя группы для обновления",
examples=["monitoring", "alerts", "devops"]
),
body: UpdateGroupRequest = Body(
...,
examples=[
{
"chat_id": -1001234567891,
"messenger": "telegram",
"thread_id": 0
},
{
"chat_id": "123456789",
"messenger": "max",
"config": {
"access_token": "your_access_token",
"api_version": "5.131"
}
}
]
)
) -> Dict[str, Any]:
"""
Обновить существующую группу в конфигурации.
Требуется API ключ в заголовке X-API-Key.
Подробная документация: см. docs/api/groups.md
"""
metrics.increment_api_endpoint("groups_update")
# Обновляем группу
try:
success = await groups_config.update_group(
group_name=group_name,
chat_id=body.chat_id,
messenger=body.messenger,
thread_id=body.thread_id,
config=body.config
)
if not success:
raise HTTPException(
status_code=400,
detail=f"Группа '{group_name}' не найдена"
)
return {
"status": "ok",
"message": f"Группа '{group_name}' обновлена"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Ошибка обновления группы: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка обновления группы: {str(e)}")
@require_api_key
@router.delete(
"/{group_name}",
name="Удалить группу",
response_model=Dict[str, Any],
dependencies=[require_api_key_dependency],
responses={
200: {
"description": "Группа успешно удалена",
"content": {
"application/json": {
"example": {
"status": "ok",
"message": "Группа 'monitoring' удалена"
}
}
}
},
400: {
"description": "Ошибка запроса (группа не найдена)",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена"}
}
}
},
401: {
"description": "Ошибка авторизации (неверный API ключ)",
"content": {
"application/json": {
"example": {"detail": "Неверный или отсутствующий API ключ"}
}
}
},
500: {
"description": "Ошибка сервера",
"content": {
"application/json": {
"example": {"detail": "Ошибка удаления группы"}
}
}
}
}
)
async def delete_group(
request: Request,
group_name: str = Path(
...,
description="Имя группы для удаления",
examples=["monitoring", "alerts", "devops"]
)
) -> Dict[str, Any]:
"""
Удалить группу из конфигурации.
Требуется API ключ в заголовке X-API-Key.
Подробная документация: см. docs/api/groups.md
"""
metrics.increment_api_endpoint("groups_delete")
# Удаляем группу
try:
success = await groups_config.delete_group(group_name)
if not success:
raise HTTPException(
status_code=400,
detail=f"Группа '{group_name}' не найдена"
)
return {
"status": "ok",
"message": f"Группа '{group_name}' удалена"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Ошибка удаления группы: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка удаления группы: {str(e)}")

View File

@@ -0,0 +1,111 @@
"""
Эндпоинты для проверки здоровья приложения.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from fastapi import APIRouter, HTTPException
from typing import Dict, Any
from app.core.metrics import metrics
from app.core.groups import groups_config
from app.core.config import get_settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/health", tags=["health"])
@router.get(
"",
name="Проверка здоровья приложения",
response_model=Dict[str, Any],
responses={
200: {
"description": "Приложение работает и готово",
"content": {
"application/json": {
"examples": {
"healthy": {
"summary": "Приложение здорово",
"value": {
"status": "healthy",
"state": "online",
"telegram_bot_configured": True,
"groups_config_available": True
}
},
"not_ready": {
"summary": "Приложение не готово",
"value": {
"status": "not_ready",
"state": "online",
"checks": {
"telegram_bot_configured": False,
"groups_config_available": True
}
}
}
}
}
}
},
503: {
"description": "Приложение не готово к работе",
"content": {
"application/json": {
"example": {
"status": "not_ready",
"checks": {
"telegram_bot_configured": False,
"groups_config_available": True
}
}
}
}
}
}
)
async def health_check() -> Dict[str, Any]:
"""
Проверка здоровья и готовности приложения для Kubernetes probes.
Объединенный endpoint для liveness и readiness probes.
Не требует аутентификации.
Подробная документация: см. docs/api/health.md
"""
metrics.increment_api_endpoint("health")
settings = get_settings()
checks = {
"telegram_bot_configured": bool(settings.telegram_bot_token),
"groups_config_available": False,
}
# Проверяем доступность конфигурации групп
try:
await groups_config.refresh_cache()
checks["groups_config_available"] = True
except Exception as e:
logger.error(f"Ошибка при проверке конфигурации групп: {e}")
checks["groups_config_available"] = False
# Если не все проверки пройдены, возвращаем 503
if not all(checks.values()):
raise HTTPException(
status_code=503,
detail={
"status": "not_ready",
"state": "online",
"checks": checks
}
)
return {
"status": "healthy",
"state": "online",
**checks
}

View File

@@ -0,0 +1,19 @@
"""
Эндпоинты для работы с Jira (только для внутреннего использования).
Создание тикетов по кнопке и работа с маппингом выполняются внутренними процессами.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Dict, Any
from fastapi import APIRouter
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/jira", tags=["jira"])
# Этот модуль оставлен пустым, так как все операции с Jira выполняются внутренними процессами
# Создание тикетов происходит автоматически при обработке алертов (см. app/modules/*.py)
# Маппинг загружается автоматически из config/jira_mapping.json (см. app/core/jira_mapping.py)

View File

@@ -0,0 +1,468 @@
"""
Эндпоинты для отправки простых сообщений в мессенджеры.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Dict, Any, Optional
from fastapi import APIRouter, HTTPException, Query, Request, Body
from app.core.metrics import metrics
from app.core.groups import groups_config
from app.core.messenger_factory import MessengerFactory
from app.core.auth import require_api_key, require_api_key_dependency
from app.models.message import (
SendMessageRequest,
SendPhotoRequest,
SendVideoRequest,
SendAudioRequest,
SendDocumentRequest
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/message", tags=["message"])
async def _send_message_to_group(
group_name: str,
thread_id: int,
messenger: Optional[str],
send_func,
*args,
**kwargs
) -> Dict[str, Any]:
"""
Вспомогательная функция для отправки сообщений в группу.
Args:
group_name: Имя группы из конфигурации.
thread_id: ID треда в группе (0 для основной группы).
messenger: Тип мессенджера (опционально).
send_func: Функция отправки сообщения.
*args: Дополнительные аргументы для функции отправки.
**kwargs: Дополнительные параметры для функции отправки.
Returns:
Результат отправки сообщения.
Raises:
HTTPException: Если группа не найдена или произошла ошибка отправки.
"""
# Получаем конфигурацию группы
group_config = await groups_config.get_group_config(group_name, messenger)
if group_config is None:
raise HTTPException(
status_code=400,
detail=f"Группа '{group_name}' не найдена в конфигурации"
)
messenger_type = group_config.get("messenger", "telegram")
chat_id = group_config.get("chat_id")
group_thread_id = group_config.get("thread_id", 0)
# Используем thread_id из параметра, если указан, иначе из конфигурации группы
final_thread_id = thread_id if thread_id > 0 else group_thread_id
# Создаем клиент мессенджера
messenger_client = MessengerFactory.create_from_config(group_config)
# Проверяем поддержку тредов
if not messenger_client.supports_threads and final_thread_id > 0:
logger.warning(f"Мессенджер '{messenger_type}' не поддерживает треды, thread_id будет проигнорирован")
final_thread_id = None
elif final_thread_id == 0:
final_thread_id = None
# Вызываем функцию отправки
success = await send_func(
messenger_client,
chat_id=chat_id,
thread_id=final_thread_id,
*args,
**kwargs
)
if not success:
raise HTTPException(
status_code=500,
detail=f"Ошибка отправки сообщения в {messenger_type}"
)
return {
"status": "ok",
"message": f"Сообщение отправлено в чат {group_name}, тред {thread_id if thread_id > 0 else 0}"
}
@require_api_key
@router.post(
"/text",
name="Отправить текстовое сообщение",
response_model=Dict[str, Any],
dependencies=[require_api_key_dependency],
responses={
200: {
"description": "Сообщение отправлено успешно",
"content": {
"application/json": {
"example": {
"status": "ok",
"message": "Сообщение отправлено в чат monitoring, тред 0"
}
}
}
},
400: {
"description": "Ошибка запроса",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
}
}
},
401: {
"description": "Требуется API ключ",
"content": {
"application/json": {
"example": {"detail": "Неверный или отсутствующий API ключ"}
}
}
},
500: {
"description": "Ошибка отправки сообщения",
"content": {
"application/json": {
"example": {"detail": "Ошибка отправки сообщения в telegram"}
}
}
}
}
)
async def send_text_message(
request: Request,
body: SendMessageRequest = Body(...),
messenger: Optional[str] = Query(None, description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы", examples=["telegram", "max"])
) -> Dict[str, Any]:
"""
Отправить текстовое сообщение в группу мессенджера.
Подробная документация: см. docs/api/message.md
"""
metrics.increment_api_endpoint("message_text")
async def send_func(client, chat_id, thread_id, **kwargs):
return await client.send_message(
chat_id=chat_id,
text=body.text,
thread_id=thread_id,
disable_web_page_preview=body.disable_web_page_preview,
parse_mode=body.parse_mode
)
return await _send_message_to_group(
group_name=body.tg_group,
thread_id=body.tg_thread_id,
messenger=messenger,
send_func=send_func
)
@require_api_key
@router.post(
"/photo",
name="Отправить фото",
response_model=Dict[str, Any],
dependencies=[require_api_key_dependency],
responses={
200: {
"description": "Фото отправлено успешно",
"content": {
"application/json": {
"example": {
"status": "ok",
"message": "Фото отправлено в чат monitoring, тред 0"
}
}
}
},
400: {
"description": "Ошибка запроса",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
}
}
},
401: {
"description": "Требуется API ключ",
"content": {
"application/json": {
"example": {"detail": "Неверный или отсутствующий API ключ"}
}
}
},
500: {
"description": "Ошибка отправки фото",
"content": {
"application/json": {
"example": {"detail": "Ошибка отправки фото в telegram"}
}
}
}
}
)
async def send_photo(
request: Request,
body: SendPhotoRequest = Body(...),
messenger: Optional[str] = Query(None, description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы", examples=["telegram", "max"])
) -> Dict[str, Any]:
"""
Отправить фото в группу мессенджера.
Подробная документация: см. docs/api/message.md
"""
metrics.increment_api_endpoint("message_photo")
async def send_func(client, chat_id, thread_id, **kwargs):
return await client.send_photo(
chat_id=chat_id,
photo=body.photo,
caption=body.caption,
thread_id=thread_id,
parse_mode=body.parse_mode
)
return await _send_message_to_group(
group_name=body.tg_group,
thread_id=body.tg_thread_id,
messenger=messenger,
send_func=send_func
)
@require_api_key
@router.post(
"/video",
name="Отправить видео",
response_model=Dict[str, Any],
dependencies=[require_api_key_dependency],
responses={
200: {
"description": "Видео отправлено успешно",
"content": {
"application/json": {
"example": {
"status": "ok",
"message": "Видео отправлено в чат monitoring, тред 0"
}
}
}
},
400: {
"description": "Ошибка запроса",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
}
}
},
401: {
"description": "Требуется API ключ",
"content": {
"application/json": {
"example": {"detail": "Неверный или отсутствующий API ключ"}
}
}
},
500: {
"description": "Ошибка отправки видео",
"content": {
"application/json": {
"example": {"detail": "Ошибка отправки видео в telegram"}
}
}
}
}
)
async def send_video(
request: Request,
body: SendVideoRequest = Body(...),
messenger: Optional[str] = Query(None, description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы", examples=["telegram", "max"])
) -> Dict[str, Any]:
"""
Отправить видео в группу мессенджера.
Подробная документация: см. docs/api/message.md
"""
metrics.increment_api_endpoint("message_video")
async def send_func(client, chat_id, thread_id, **kwargs):
return await client.send_video(
chat_id=chat_id,
video=body.video,
caption=body.caption,
thread_id=thread_id,
parse_mode=body.parse_mode,
duration=body.duration,
width=body.width,
height=body.height
)
return await _send_message_to_group(
group_name=body.tg_group,
thread_id=body.tg_thread_id,
messenger=messenger,
send_func=send_func
)
@require_api_key
@router.post(
"/audio",
name="Отправить аудио",
response_model=Dict[str, Any],
dependencies=[require_api_key_dependency],
responses={
200: {
"description": "Аудио отправлено успешно",
"content": {
"application/json": {
"example": {
"status": "ok",
"message": "Аудио отправлено в чат monitoring, тред 0"
}
}
}
},
400: {
"description": "Ошибка запроса",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
}
}
},
401: {
"description": "Требуется API ключ",
"content": {
"application/json": {
"example": {"detail": "Неверный или отсутствующий API ключ"}
}
}
},
500: {
"description": "Ошибка отправки аудио",
"content": {
"application/json": {
"example": {"detail": "Ошибка отправки аудио в telegram"}
}
}
}
}
)
async def send_audio(
request: Request,
body: SendAudioRequest = Body(...),
messenger: Optional[str] = Query(None, description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы", examples=["telegram", "max"])
) -> Dict[str, Any]:
"""
Отправить аудио в группу мессенджера.
Подробная документация: см. docs/api/message.md
"""
metrics.increment_api_endpoint("message_audio")
async def send_func(client, chat_id, thread_id, **kwargs):
return await client.send_audio(
chat_id=chat_id,
audio=body.audio,
caption=body.caption,
thread_id=thread_id,
parse_mode=body.parse_mode,
duration=body.duration,
performer=body.performer,
title=body.title
)
return await _send_message_to_group(
group_name=body.tg_group,
thread_id=body.tg_thread_id,
messenger=messenger,
send_func=send_func
)
@require_api_key
@router.post(
"/document",
name="Отправить документ",
response_model=Dict[str, Any],
dependencies=[require_api_key_dependency],
responses={
200: {
"description": "Документ отправлен успешно",
"content": {
"application/json": {
"example": {
"status": "ok",
"message": "Документ отправлен в чат monitoring, тред 0"
}
}
}
},
400: {
"description": "Ошибка запроса",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
}
}
},
401: {
"description": "Требуется API ключ",
"content": {
"application/json": {
"example": {"detail": "Неверный или отсутствующий API ключ"}
}
}
},
500: {
"description": "Ошибка отправки документа",
"content": {
"application/json": {
"example": {"detail": "Ошибка отправки документа в telegram"}
}
}
}
}
)
async def send_document(
request: Request,
body: SendDocumentRequest = Body(...),
messenger: Optional[str] = Query(None, description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы", examples=["telegram", "max"])
) -> Dict[str, Any]:
"""
Отправить документ в группу мессенджера.
Подробная документация: см. docs/api/message.md
"""
metrics.increment_api_endpoint("message_document")
async def send_func(client, chat_id, thread_id, **kwargs):
return await client.send_document(
chat_id=chat_id,
document=body.document,
caption=body.caption,
thread_id=thread_id,
parse_mode=body.parse_mode,
filename=body.filename
)
return await _send_message_to_group(
group_name=body.tg_group,
thread_id=body.tg_thread_id,
messenger=messenger,
send_func=send_func
)

View File

@@ -0,0 +1,403 @@
"""
Эндпоинты для обработки webhooks из систем мониторинга (Grafana, Zabbix, AlertManager).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from fastapi import APIRouter, HTTPException, Path, Body, Query
from typing import Dict, Optional
from app.models.grafana import GrafanaAlert
from app.models.zabbix import ZabbixAlert
from app.models.alertmanager import PrometheusAlert
from app.modules.grafana import send as grafana_send
from app.modules.zabbix import send as zabbix_send
from app.modules.alertmanager import send as alertmanager_send
from app.core.metrics import metrics
logger = logging.getLogger(__name__)
router = APIRouter(tags=["monitoring"])
@router.post(
"/grafana/{group_name}/{thread_id}",
name="Отправка вебхуков из Grafana",
response_model=Dict[str, str],
summary="Отправить алерт из Grafana",
description="Эндпоинт для обработки webhooks из Grafana. **Не требует авторизации.**",
responses={
200: {
"description": "Сообщение успешно отправлено",
"content": {
"application/json": {
"example": {"status": "ok", "message": "Сообщение отправлено"}
}
}
},
400: {
"description": "Некорректные данные запроса",
"content": {
"application/json": {
"example": {"detail": "Неверный формат данных для Grafana алерта"}
}
}
},
404: {
"description": "Группа не найдена",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
}
}
},
500: {
"description": "Ошибка сервера",
"content": {
"application/json": {
"example": {"detail": "Ошибка отправки сообщения"}
}
}
}
}
)
async def send_grafana_alert(
group_name: str = Path(
...,
description="Имя группы из конфигурации (config/groups.json)",
examples=["monitoring", "alerts", "devops"]
),
thread_id: int = Path(
...,
description="ID треда в группе (0 для отправки в основную группу без треда, поддерживается только для Telegram)",
examples=[0, 123, 456]
),
messenger: Optional[str] = Query(
None,
description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы",
examples=["telegram", "max"]
),
alert: GrafanaAlert = Body(
...,
description="Данные алерта из Grafana",
examples=[
{
"title": "[Alerting] High CPU Usage",
"ruleId": 674180201771804383,
"ruleName": "High CPU Usage Alert",
"state": "alerting",
"evalMatches": [
{
"value": 95.5,
"metric": "cpu_usage_percent",
"tags": {"host": "server01", "instance": "production"}
}
],
"orgId": 1,
"dashboardId": 123,
"panelId": 456,
"tags": {"severity": "critical", "environment": "production"},
"ruleUrl": "http://grafana.cism-ms.ru/alerting/list",
"message": "CPU usage is above 90% threshold for more than 5 minutes"
},
{
"title": "[OK] High CPU Usage",
"ruleId": 674180201771804383,
"ruleName": "High CPU Usage Alert",
"state": "ok",
"evalMatches": [
{
"value": 45.2,
"metric": "cpu_usage_percent",
"tags": {"host": "server01", "instance": "production"}
}
],
"orgId": 1,
"dashboardId": 123,
"panelId": 456,
"tags": {"severity": "critical", "environment": "production"},
"ruleUrl": "http://grafana.cism-ms.ru/alerting/list",
"message": "CPU usage has returned to normal levels"
}
]
)
) -> Dict[str, str]:
"""
Отправить алерт из Grafana в мессенджер.
Принимает webhook от Grafana и отправляет сообщение в указанную группу мессенджера.
Не требует авторизации (API ключ не нужен).
Подробная документация: см. docs/monitoring/grafana.md
"""
metrics.increment_api_endpoint("grafana")
logger.info(f"Получен алерт Grafana для группы {group_name}, тред {thread_id}, мессенджер {messenger}")
try:
await grafana_send(group_name, thread_id, alert, messenger)
return {
"status": "ok",
"message": "Сообщение отправлено"
}
except ValueError as e:
logger.error(f"Ошибка валидации: {e}")
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Ошибка отправки алерта Grafana: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка отправки сообщения: {str(e)}")
@router.post(
"/zabbix/{group_name}/{thread_id}",
name="Отправка вебхуков из Zabbix",
response_model=Dict[str, str],
summary="Отправить алерт из Zabbix",
description="Эндпоинт для обработки webhooks из Zabbix. **Не требует авторизации.**",
responses={
200: {
"description": "Сообщение успешно отправлено",
"content": {
"application/json": {
"example": {"status": "ok", "message": "Сообщение отправлено"}
}
}
},
400: {
"description": "Некорректные данные запроса",
"content": {
"application/json": {
"example": {"detail": "Неверный формат данных для Zabbix алерта"}
}
}
},
404: {
"description": "Группа не найдена",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
}
}
},
500: {
"description": "Ошибка сервера",
"content": {
"application/json": {
"example": {"detail": "Ошибка отправки сообщения"}
}
}
}
}
)
async def send_zabbix_alert(
group_name: str = Path(
...,
description="Имя группы из конфигурации (config/groups.json)",
examples=["monitoring", "alerts", "devops"]
),
thread_id: int = Path(
...,
description="ID треда в группе (0 для отправки в основную группу без треда, поддерживается только для Telegram)",
examples=[0, 123, 456]
),
messenger: Optional[str] = Query(
None,
description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы",
examples=["telegram", "max"]
),
alert: ZabbixAlert = Body(
...,
description="Данные алерта из Zabbix",
examples=[
{
"link": "https://zabbix.example.com/tr_events.php?triggerid=42667&eventid=8819711",
"status": "PROBLEM",
"action-id": "7",
"alert-subject": "Problem: High CPU utilization (over 90% for 5m)",
"alert-message": "Problem started at 16:48:44 on 2024.02.08\r\nProblem name: High CPU utilization (over 90% for 5m)\r\nHost: pnode28\r\nSeverity: Warning\r\nCurrent utilization: 95.2 %\r\n",
"event-id": "8819711",
"event-name": "High CPU utilization (over 90% for 5m)",
"event-nseverity": "2",
"event-opdata": "Current utilization: 95.2 %",
"event-severity": "Warning",
"host-name": "pnode28",
"host-ip": "10.14.253.38",
"host-port": "10050"
},
{
"link": "https://zabbix.example.com/tr_events.php?triggerid=42667&eventid=8819711",
"status": "OK",
"action-id": "7",
"alert-subject": "Resolved in 1m 0s: High CPU utilization (over 90% for 5m)",
"alert-message": "Problem has been resolved at 16:49:44 on 2024.02.08\r\nProblem name: High CPU utilization (over 90% for 5m)\r\nProblem duration: 1m 0s\r\nHost: pnode28\r\nSeverity: Warning\r\nOriginal problem ID: 8819711\r\n",
"event-id": "8819711",
"event-name": "High CPU utilization (over 90% for 5m)",
"event-nseverity": "2",
"event-opdata": "Current utilization: 70.9 %",
"event-recovery-date": "2024.02.08",
"event-recovery-time": "16:49:44",
"event-duration": "1m 0s",
"event-recovery-name": "High CPU utilization (over 90% for 5m)",
"event-recovery-status": "RESOLVED",
"event-recovery-tags": "Application:CPU",
"event-severity": "Warning",
"host-name": "pnode28",
"host-ip": "10.14.253.38",
"host-port": "10050"
}
]
)
) -> Dict[str, str]:
"""
Отправить алерт из Zabbix в мессенджер.
Принимает webhook от Zabbix и отправляет сообщение в указанную группу мессенджера.
Не требует авторизации (API ключ не нужен).
Подробная документация: см. docs/monitoring/zabbix.md
"""
metrics.increment_api_endpoint("zabbix")
logger.info(f"Получен алерт Zabbix для группы {group_name}, тред {thread_id}, мессенджер {messenger}")
try:
await zabbix_send(group_name, thread_id, alert, messenger)
return {
"status": "ok",
"message": "Сообщение отправлено"
}
except ValueError as e:
logger.error(f"Ошибка валидации: {e}")
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Ошибка отправки алерта Zabbix: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка отправки сообщения: {str(e)}")
@router.post(
"/alertmanager/{k8s_cluster}/{group_name}/{thread_id}",
name="Отправка вебхуков из AlertManager",
response_model=Dict[str, str],
summary="Отправить алерт из AlertManager",
description="Эндпоинт для обработки webhooks из AlertManager. **Не требует авторизации.**",
responses={
200: {
"description": "Сообщение успешно отправлено",
"content": {
"application/json": {
"example": {"status": "ok", "message": "Сообщение отправлено"}
}
}
},
400: {
"description": "Некорректные данные запроса",
"content": {
"application/json": {
"example": {"detail": "Неверный формат данных для AlertManager алерта"}
}
}
},
404: {
"description": "Группа не найдена",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
}
}
},
500: {
"description": "Ошибка сервера",
"content": {
"application/json": {
"example": {"detail": "Ошибка отправки сообщения"}
}
}
}
}
)
async def send_alertmanager_alert(
k8s_cluster: str = Path(
...,
description="Имя Kubernetes кластера (используется для формирования URL к Grafana/Prometheus)",
examples=["production", "staging", "development"]
),
group_name: str = Path(
...,
description="Имя группы из конфигурации (config/groups.json)",
examples=["monitoring", "alerts", "devops"]
),
thread_id: int = Path(
...,
description="ID треда в группе (0 для отправки в основную группу без треда, поддерживается только для Telegram)",
examples=[0, 123, 456]
),
messenger: Optional[str] = Query(
None,
description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы",
examples=["telegram", "max"]
),
alert: PrometheusAlert = Body(
...,
description="Данные алерта из AlertManager",
examples=[
{
"status": "firing",
"externalURL": "http://alertmanager.example.com",
"commonLabels": {
"alertname": "HighCPUUsage",
"severity": "critical",
"namespace": "production",
"pod": "app-deployment-7d8f9b4c5-abc123",
"container": "app-container"
},
"commonAnnotations": {
"summary": "High CPU usage detected in production namespace",
"description": "CPU usage is above 90% for 5 minutes on pod app-deployment-7d8f9b4c5-abc123",
"runbook_url": "https://wiki.example.com/runbooks/high-cpu-usage"
}
},
{
"status": "resolved",
"externalURL": "http://alertmanager.example.com",
"commonLabels": {
"alertname": "HighCPUUsage",
"severity": "critical",
"namespace": "production",
"pod": "app-deployment-7d8f9b4c5-abc123",
"container": "app-container"
},
"commonAnnotations": {
"summary": "High CPU usage resolved in production namespace",
"description": "CPU usage has returned to normal levels on pod app-deployment-7d8f9b4c5-abc123"
}
}
]
)
) -> Dict[str, str]:
"""
Отправить алерт из AlertManager в мессенджер.
Эндпоинт для обработки webhooks из AlertManager.
Не требует авторизации (API ключ не нужен).
Подробная документация: см. docs/monitoring/alertmanager.md
"""
metrics.increment_api_endpoint("alertmanager")
logger.info(f"Получен алерт AlertManager для кластера {k8s_cluster}, группы {group_name}, тред {thread_id}, мессенджер {messenger}")
try:
if not isinstance(alert, PrometheusAlert):
raise HTTPException(status_code=400, detail="Неверный формат данных для AlertManager алерта")
await alertmanager_send(k8s_cluster, group_name, thread_id, alert, messenger)
return {
"status": "ok",
"message": "Сообщение отправлено"
}
except HTTPException:
raise
except ValueError as e:
logger.error(f"Ошибка валидации: {e}")
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Ошибка отправки алерта AlertManager: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка отправки сообщения: {str(e)}")

27
app/api/v1/router.py Normal file
View File

@@ -0,0 +1,27 @@
"""
Роутер API версии 1.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from fastapi import APIRouter
from app.api.v1.endpoints import (
health,
monitoring,
debug,
jira,
message,
groups,
)
# Создаем роутер для API v1
api_router = APIRouter(prefix="/api/v1")
# Подключаем эндпоинты
api_router.include_router(health.router)
api_router.include_router(monitoring.router)
api_router.include_router(debug.router)
api_router.include_router(jira.router)
api_router.include_router(message.router)
api_router.include_router(groups.router)

0
app/common/__init__.py Normal file
View File

34
app/common/cors.py Normal file
View File

@@ -0,0 +1,34 @@
"""
Настройка CORS для приложения.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from fastapi.middleware.cors import CORSMiddleware
from typing import List
from app.core.config import settings
logger = logging.getLogger(__name__)
def add(app):
"""
Добавить CORS middleware к приложению FastAPI.
Args:
app: Экземпляр приложения FastAPI.
"""
# Разрешаем все источники (можно настроить через переменные окружения)
allow_origins: List[str] = ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=allow_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
logger.info("CORS middleware добавлен")

41
app/common/logger.py Normal file
View File

@@ -0,0 +1,41 @@
"""
Настройка логирования приложения.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
import os
from opentelemetry.instrumentation.logging import LoggingInstrumentor
# Настраиваем уровень логирования сначала
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Настройка логирования с поддержкой OpenTelemetry
try:
from app.core.config import settings
if settings.otel_enabled:
LoggingInstrumentor().instrument(
set_logging_format=True,
logging_format='%(levelname)s:\t %(message)s traceID=%(otelTraceID)s spanID=%(otelSpanID)s'
)
logger.info("OpenTelemetry логирование включено")
else:
LoggingInstrumentor().instrument(
set_logging_format=True,
logging_format='%(levelname)s:\t %(message)s'
)
except Exception as e:
logger.warning(f"Ошибка настройки OpenTelemetry логирования: {e}")
# Используем базовое логирование
LoggingInstrumentor().instrument(
set_logging_format=True,
logging_format='%(levelname)s:\t %(message)s'
)

32
app/common/metrics.py Normal file
View File

@@ -0,0 +1,32 @@
from prometheus_client import make_asgi_app, Counter, Gauge, CollectorRegistry, push_to_gateway # Добавляем метрики для прометея
from fastapi import Request
import os
#def add_middleware(app):
# # Обьявляем метрики, которые будем собирать
# registry = getMetricsRegistry()
# all_requests = Counter('tg_monitoring_gateway_all_requests_counter', 'Счетчик запросов', registry=registry)
# # Запускаем счетчик запросов
# @app.middleware("request_count")
# def request_count(request: Request, call_next):
# all_requests.inc()
# pushMetricsRegistry(registry, all_requests)
# response = call_next(request)
# return response
#def getMetricsRegistry():
# registry = CollectorRegistry()
# return(registry)
#def pushMetricsRegistry(registry, metric):
# pushgateway_url = os.getenv('PUSHGATEWAY_URL')
# pushgateway_job = os.getenv('PUSHGATEWAY_JOB')
# push_to_gateway(pushgateway_url, job=pushgateway_job, registry=registry)
# return(metric)
#def requestsCount(registry, endpoint):
# all_requests = Counter('tg_monitoring_gateway_api_requests_counter', 'Счетчик запросов к API', labelnames=['endpoint'], registry=registry)
# all_requests.labels(endpoint=endpoint).inc()
# pushMetricsRegistry(registry, all_requests)
# return(endpoint)

69
app/common/telemetry.py Normal file
View File

@@ -0,0 +1,69 @@
"""
Настройка телеметрии OpenTelemetry.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
logger = logging.getLogger(__name__)
def setup_telemetry():
"""
Настройка провайдера телеметрии OpenTelemetry.
Returns:
TracerProvider: Провайдер трейсинга.
"""
from app.core.config import settings
resource = Resource(attributes={SERVICE_NAME: settings.otel_service_name})
sampler = TraceIdRatioBased(1.0) # 100% семплирование
# Настройка экспортера OTLP
exporter_config = {}
if settings.otel_exporter_otlp_endpoint:
exporter_config['endpoint'] = settings.otel_exporter_otlp_endpoint
exporter_config['insecure'] = settings.otel_exporter_otlp_insecure
exporter = OTLPSpanExporter(**exporter_config)
provider = TracerProvider(sampler=sampler, resource=resource)
processor = SimpleSpanProcessor(exporter)
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
logger.info(f"Telemetry настроен для сервиса: {settings.otel_service_name}")
return provider
def add(app):
"""
Добавить инструментацию OpenTelemetry к приложению FastAPI.
Args:
app: Экземпляр приложения FastAPI.
"""
from app.core.config import settings
if settings.otel_enabled:
try:
tracer_provider = setup_telemetry()
FastAPIInstrumentor.instrument_app(
app,
tracer_provider=tracer_provider,
excluded_urls="/openapi.json,/docs,/redoc"
)
logger.info("OpenTelemetry инструментация добавлена")
except Exception as e:
logger.error(f"Ошибка настройки OpenTelemetry: {e}")
else:
logger.info("OpenTelemetry отключен")

6
app/core/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
Общие утилиты и функции для приложения.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""

183
app/core/auth.py Normal file
View File

@@ -0,0 +1,183 @@
"""
Утилиты для аутентификации и авторизации.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Optional, Callable, Any
from functools import wraps
from fastapi import HTTPException, Security, Depends, Request
from fastapi.security import APIKeyHeader
from starlette.requests import Request as StarletteRequest
from app.core.config import get_settings
logger = logging.getLogger(__name__)
# Request может быть как из FastAPI, так и из Starlette
# Оба типа совместимы, поэтому используем StarletteRequest как базовый тип
RequestType = StarletteRequest
# Схема безопасности для API ключа
api_key_header = APIKeyHeader(
name="X-API-Key",
auto_error=False,
description="API ключ для авторизации"
)
def verify_api_key(api_key: Optional[str] = Security(api_key_header)) -> bool:
"""
Проверить API ключ для авторизации (обязательная авторизация).
Используется как зависимость FastAPI (Depends) для отображения в Swagger UI.
Также помечает контекст запроса, что API ключ был проверен.
Args:
api_key: API ключ из заголовка X-API-Key.
Returns:
True если API ключ верный.
Raises:
HTTPException: Если API ключ неверный или не указан.
"""
settings = get_settings()
# Если API ключ не установлен в настройках, доступ запрещен
if not settings.api_key:
logger.warning("API_KEY не установлен - доступ запрещен")
raise HTTPException(
status_code=401,
detail="API ключ не настроен на сервере"
)
# Если API ключ не передан, доступ запрещен
if not api_key:
raise HTTPException(
status_code=401,
detail="Неверный или отсутствующий API ключ",
headers={"WWW-Authenticate": "ApiKey"}
)
# Проверяем API ключ
if api_key != settings.api_key:
logger.warning(f"Неверный API ключ: {api_key[:10]}...")
raise HTTPException(
status_code=401,
detail="Неверный или отсутствующий API ключ",
headers={"WWW-Authenticate": "ApiKey"}
)
# Помечаем, что API ключ был проверен через dependency
# Это позволяет декоратору @require_api_key не выполнять повторную проверку
try:
from starlette.context import contextvars
# Используем contextvars для хранения информации о проверке
# Но это может не работать во всех случаях
pass
except ImportError:
pass
return True
def verify_api_key_optional(api_key: Optional[str] = Security(api_key_header)) -> Optional[bool]:
"""
Проверить API ключ для авторизации (опциональная авторизация).
Используется как зависимость FastAPI (Depends).
Args:
api_key: API ключ из заголовка X-API-Key.
Returns:
True если API ключ верный, None если не передан, выбрасывает исключение если неверный.
Raises:
HTTPException: Если API ключ неверный.
"""
settings = get_settings()
# Если API ключ не установлен в настройках, возвращаем None (нет авторизации)
if not settings.api_key:
return None
# Если API ключ не передан, возвращаем None (нет авторизации)
if not api_key:
return None
# Проверяем API ключ
if api_key != settings.api_key:
logger.warning(f"Неверный API ключ: {api_key[:10]}...")
raise HTTPException(
status_code=401,
detail="Неверный или отсутствующий API ключ",
headers={"WWW-Authenticate": "ApiKey"}
)
return True
# Удобные константы для использования в endpoints (через dependencies)
require_api_key_dependency = Depends(verify_api_key)
require_api_key_optional = Depends(verify_api_key_optional)
def require_api_key(func: Callable) -> Callable:
"""
Декоратор для пометки функции как требующей API ключ.
Использование:
@require_api_key
@router.post("/endpoint", dependencies=[require_api_key_dependency])
async def my_endpoint(request: Request, ...):
...
Примечание: Декоратор используется только для пометки функции.
Фактическая проверка API ключа выполняется через `dependencies=[require_api_key_dependency]`,
который также обеспечивает отображение замочка в Swagger UI.
Декоратор не выполняет проверку API ключа - это делает dependency.
Декоратор оставлен для удобства и возможного расширения в будущем.
Args:
func: Функция для декорирования.
Returns:
Декорированная функция с пометкой о необходимости API ключа.
"""
# Помечаем функцию, что она требует API ключ
func.__requires_api_key__ = True
# Просто возвращаем функцию без изменений
# Проверка API ключа выполняется через dependency
return func
def hide_from_api(func: Callable) -> Callable:
"""
Декоратор для скрытия эндпоинта из API документации (Swagger UI).
Использование:
@hide_from_api
@router.post("/debug/dump")
async def debug_endpoint(...):
...
Примечание: Декоратор помечает функцию как скрытую от API.
Эндпоинт все еще будет работать, но не будет отображаться в Swagger UI.
Декоратор должен быть применен ПЕРЕД декоратором route (снизу вверх).
Args:
func: Функция для декорирования.
Returns:
Декорированная функция с пометкой о скрытии от API.
"""
# Помечаем функцию, что она должна быть скрыта от API
func.__hide_from_api__ = True
# Просто возвращаем функцию без изменений
# Скрытие из API выполняется в custom_openapi
return func

127
app/core/button_utils.py Normal file
View File

@@ -0,0 +1,127 @@
"""
Утилиты для работы с кнопками в различных мессенджерах.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Optional, Dict, Any, List
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
logger = logging.getLogger(__name__)
def convert_telegram_buttons_to_dict(buttons: Optional[InlineKeyboardMarkup]) -> Optional[Dict[str, Any]]:
"""
Преобразовать кнопки Telegram (InlineKeyboardMarkup) в универсальный формат Dict.
Args:
buttons: InlineKeyboardMarkup с кнопками или None.
Returns:
Словарь с кнопками в универсальном формате или None.
Формат:
{
"inline_keyboard": [
[
{"text": "Кнопка 1", "url": "https://example.com"},
{"text": "Кнопка 2", "url": "https://example2.com"}
],
[
{"text": "Кнопка 3", "url": "https://example3.com"}
]
]
}
"""
if buttons is None:
return None
if not isinstance(buttons, InlineKeyboardMarkup):
logger.warning(f"Неизвестный тип кнопок: {type(buttons)}")
return None
inline_keyboard = []
for row in buttons.inline_keyboard:
row_buttons = []
for button in row:
if isinstance(button, InlineKeyboardButton):
button_dict = {"text": button.text}
if button.url:
button_dict["url"] = button.url
if button.callback_data:
button_dict["callback_data"] = button.callback_data
row_buttons.append(button_dict)
if row_buttons:
inline_keyboard.append(row_buttons)
if not inline_keyboard:
return None
return {"inline_keyboard": inline_keyboard}
def convert_dict_to_telegram_buttons(buttons_dict: Optional[Dict[str, Any]]) -> Optional[InlineKeyboardMarkup]:
"""
Преобразовать универсальный формат Dict в кнопки Telegram (InlineKeyboardMarkup).
Args:
buttons_dict: Словарь с кнопками в универсальном формате или None.
Returns:
InlineKeyboardMarkup с кнопками или None.
"""
if buttons_dict is None:
return None
inline_keyboard_data = buttons_dict.get("inline_keyboard", [])
if not inline_keyboard_data:
return None
inline_keyboard = []
for row in inline_keyboard_data:
row_buttons = []
for button_data in row:
if isinstance(button_data, dict):
text = button_data.get("text")
url = button_data.get("url")
callback_data = button_data.get("callback_data")
if text:
if url:
row_buttons.append(InlineKeyboardButton(text=text, url=url))
elif callback_data:
row_buttons.append(InlineKeyboardButton(text=text, callback_data=callback_data))
if row_buttons:
inline_keyboard.append(row_buttons)
if not inline_keyboard:
return None
return InlineKeyboardMarkup(inline_keyboard)
def convert_dict_to_vk_buttons(buttons_dict: Optional[Dict[str, Any]]) -> Optional[str]:
"""
Преобразовать универсальный формат Dict в кнопки VK (JSON строка).
Args:
buttons_dict: Словарь с кнопками в универсальном формате или None.
Returns:
JSON строка с кнопками для VK API или None.
"""
if buttons_dict is None:
return None
inline_keyboard_data = buttons_dict.get("inline_keyboard", [])
if not inline_keyboard_data:
return None
# Формируем клавиатуру для VK API
# VK API использует другой формат клавиатуры
# Для простоты возвращаем None, так как VK API требует более сложной структуры
# В реальной реализации нужно преобразовать в формат VK Keyboard
logger.warning("Преобразование кнопок в формат VK пока не реализовано полностью")
return None

132
app/core/config.py Normal file
View File

@@ -0,0 +1,132 @@
"""
Конфигурация приложения.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import os
import logging
from typing import Optional
from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__)
class Settings(BaseSettings):
"""Настройки приложения из переменных окружения."""
# Telegram настройки
telegram_bot_token: Optional[str] = None
telegram_enabled: bool = True
# MAX/VK настройки
max_access_token: Optional[str] = None
max_api_version: str = "5.131"
max_enabled: bool = False
# Общие настройки мессенджеров
default_messenger: str = "telegram" # По умолчанию Telegram
# Файлы конфигурации
groups_config_path: str = "/app/config/groups.json"
templates_path: str = "/app/templates"
# API ключ для авторизации
api_key: Optional[str] = None
# Grafana настройки
grafana_url: Optional[str] = None
# Zabbix настройки
zabbix_url: Optional[str] = None
# Kubernetes кластер настройки
k8s_cluster_grafana_subdomain: Optional[str] = None
k8s_cluster_prometheus_subdomain: Optional[str] = None
k8s_cluster_alertmanager_subdomain: Optional[str] = None
# Prometheus Pushgateway настройки
pushgateway_url: Optional[str] = None
pushgateway_job: str = "MessageGateway"
# OpenTelemetry настройки
otel_enabled: bool = False
otel_service_name: str = "monitoring-message-gateway"
otel_exporter_otlp_endpoint: Optional[str] = None
otel_exporter_otlp_protocol: str = "http/json"
otel_traces_exporter: str = "otlp_proto_http"
otel_exporter_otlp_insecure: bool = True
otel_python_log_correlation: bool = False
# Jira настройки
jira_enabled: bool = False
jira_url: Optional[str] = None
jira_email: Optional[str] = None
jira_api_token: Optional[str] = None
jira_project_key: Optional[str] = None
jira_default_assignee: Optional[str] = None
jira_default_issue_type: str = "Bug"
jira_mapping_config_path: str = "/app/config/jira_mapping.json"
jira_create_on_alert: bool = True
jira_create_on_resolved: bool = False
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
env_ignore_empty=True
)
def validate_required(self) -> None:
"""Проверка обязательных переменных окружения."""
if not self.telegram_bot_token:
logger.warning("TELEGRAM_BOT_TOKEN не установлен - приложение может не работать")
# Не выбрасываем исключение, чтобы приложение могло запуститься
def get_k8s_grafana_url(self, cluster: str) -> str:
"""Получить URL Grafana для Kubernetes кластера."""
if not self.k8s_cluster_grafana_subdomain:
raise ValueError("K8S_CLUSTER_GRAFANA_SUBDOMAIN не установлен")
return f"{cluster}.{self.k8s_cluster_grafana_subdomain}"
def get_k8s_prometheus_url(self, cluster: str) -> str:
"""Получить URL Prometheus для Kubernetes кластера."""
if not self.k8s_cluster_prometheus_subdomain:
raise ValueError("K8S_CLUSTER_PROMETHEUS_SUBDOMAIN не установлен")
return f"{cluster}.{self.k8s_cluster_prometheus_subdomain}"
def get_k8s_alertmanager_url(self, cluster: str) -> str:
"""Получить URL AlertManager для Kubernetes кластера."""
if not self.k8s_cluster_alertmanager_subdomain:
raise ValueError("K8S_CLUSTER_ALERTMANAGER_SUBDOMAIN не установлен")
return f"{cluster}.{self.k8s_cluster_alertmanager_subdomain}"
# Глобальный экземпляр настроек (валидация отложена до первого использования)
_settings_instance: Optional[Settings] = None
def get_settings() -> Settings:
"""
Получить экземпляр настроек (lazy initialization).
Returns:
Экземпляр Settings.
"""
global _settings_instance
if _settings_instance is None:
_settings_instance = Settings()
return _settings_instance
# Глобальный экземпляр настроек (lazy initialization)
class _SettingsProxy:
"""Прокси для ленивой инициализации settings."""
def __getattr__(self, name):
"""Получить атрибут из settings."""
return getattr(get_settings(), name)
settings = _SettingsProxy()

370
app/core/groups.py Normal file
View File

@@ -0,0 +1,370 @@
"""
Управление конфигурацией групп для различных мессенджеров.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import json
import logging
from typing import Dict, Optional, Any, Union
import aiofiles
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
class GroupsConfig:
"""Менеджер конфигурации групп для различных мессенджеров с кэшированием."""
def __init__(self, config_path: Optional[str] = None):
"""
Инициализация менеджера конфигурации.
Args:
config_path: Путь к файлу конфигурации групп.
"""
from app.core.config import get_settings
settings = get_settings()
self.config_path = config_path or settings.groups_config_path
self._cache: Optional[Dict[str, Any]] = None
self._cache_time: Optional[datetime] = None
self._cache_ttl = timedelta(minutes=5) # Кэш на 5 минут
self.default_messenger = settings.default_messenger
async def _load_config(self) -> Dict[str, Any]:
"""
Загрузить конфигурацию групп из файла.
Returns:
Конфигурация групп в формате:
{
"group_name": {
"messenger": "telegram",
"chat_id": -1001997464975,
"thread_id": 0,
"config": {}
}
}
Raises:
FileNotFoundError: Если файл конфигурации не найден.
json.JSONDecodeError: Если файл содержит некорректный JSON.
ValueError: Если конфигурация имеет неверный формат.
"""
try:
async with aiofiles.open(self.config_path, 'r', encoding='utf-8') as f:
content = await f.read()
config = json.loads(content)
logger.info(f"Конфигурация групп загружена из {self.config_path}")
# Валидация формата конфигурации
for group_name, group_value in config.items():
if not isinstance(group_value, dict):
raise ValueError(
f"Неверный формат конфигурации для группы '{group_name}': "
f"ожидается словарь, получен {type(group_value)}. "
f"Используйте формат: {{'messenger': 'telegram', 'chat_id': ..., 'thread_id': 0, 'config': {{}}}}"
)
if "chat_id" not in group_value:
raise ValueError(
f"Отсутствует обязательное поле 'chat_id' для группы '{group_name}'"
)
if "messenger" not in group_value:
raise ValueError(
f"Отсутствует обязательное поле 'messenger' для группы '{group_name}'"
)
return config
except FileNotFoundError:
logger.error(f"Файл конфигурации групп не найден: {self.config_path}")
raise
except json.JSONDecodeError as e:
logger.error(f"Ошибка парсинга JSON в файле конфигурации: {e}")
raise
def _is_cache_valid(self) -> bool:
"""Проверить, валиден ли кэш."""
if self._cache is None or self._cache_time is None:
return False
return datetime.now() - self._cache_time < self._cache_ttl
async def get_group_config(self, group_name: str, messenger: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""
Получить конфигурацию группы.
Args:
group_name: Имя группы из конфигурации.
messenger: Тип мессенджера (опционально, для фильтрации).
Returns:
Конфигурация группы или None, если группа не найдена.
Формат:
{
"messenger": "telegram",
"chat_id": -1001997464975,
"thread_id": 0,
"config": {}
}
"""
# Проверяем кэш
if not self._is_cache_valid():
try:
self._cache = await self._load_config()
self._cache_time = datetime.now()
except (FileNotFoundError, json.JSONDecodeError):
logger.error("Не удалось загрузить конфигурацию групп")
return None
group_config = self._cache.get(group_name)
if group_config is None:
logger.warning(f"Группа '{group_name}' не найдена в конфигурации")
return None
# Если указан messenger, проверяем соответствие
if messenger and group_config.get("messenger") != messenger:
logger.warning(
f"Группа '{group_name}' имеет мессенджер '{group_config.get('messenger')}', "
f"но запрошен '{messenger}'"
)
return None
logger.info(f"Найдена конфигурация для группы '{group_name}': {group_config}")
return group_config
async def get_chat_id(self, group_name: str, messenger: Optional[str] = None) -> Optional[Union[int, str]]:
"""
Получить ID чата по имени группы.
Args:
group_name: Имя группы из конфигурации.
messenger: Тип мессенджера (опционально).
Returns:
ID чата или None, если группа не найдена.
"""
group_config = await self.get_group_config(group_name, messenger)
if group_config is None:
return None
return group_config.get("chat_id")
async def refresh_cache(self) -> None:
"""Принудительно обновить кэш конфигурации."""
try:
self._cache = await self._load_config()
self._cache_time = datetime.now()
logger.info("Кэш конфигурации групп обновлен")
except (FileNotFoundError, json.JSONDecodeError) as e:
logger.error(f"Ошибка обновления кэша: {e}")
async def _save_config(self, config: Dict[str, Any]) -> None:
"""
Сохранить конфигурацию групп в файл.
Args:
config: Нормализованная конфигурация групп в новом формате.
Raises:
IOError: Если не удалось записать файл.
"""
try:
async with aiofiles.open(self.config_path, 'w', encoding='utf-8') as f:
await f.write(json.dumps(config, indent=2, ensure_ascii=False))
logger.info(f"Конфигурация групп сохранена в {self.config_path}")
# Обновляем кэш
self._cache = config
self._cache_time = datetime.now()
except IOError as e:
logger.error(f"Ошибка записи конфигурации групп: {e}")
raise
async def get_all_groups(self, include_ids: bool = False, messenger: Optional[str] = None) -> Dict[str, Any]:
"""
Получить все группы из конфигурации.
Args:
include_ids: Включать ли полную конфигурацию групп (включая ID, мессенджер и т.д.).
messenger: Фильтр по типу мессенджера (опционально).
Returns:
Словарь с группами.
Если include_ids=False, возвращает только названия групп.
Если include_ids=True, возвращает полную конфигурацию групп.
"""
if not self._is_cache_valid():
try:
self._cache = await self._load_config()
self._cache_time = datetime.now()
except (FileNotFoundError, json.JSONDecodeError):
logger.error("Не удалось загрузить конфигурацию групп")
return {}
# Фильтруем по мессенджеру, если указан
filtered_config = self._cache.copy()
if messenger:
filtered_config = {
name: config
for name, config in filtered_config.items()
if config.get("messenger") == messenger
}
if include_ids:
return filtered_config.copy()
else:
# Возвращаем только названия групп без конфигурации
return {name: None for name in filtered_config.keys()}
async def create_group(
self,
group_name: str,
chat_id: Union[int, str],
messenger: str = "telegram",
thread_id: int = 0,
config: Optional[Dict[str, Any]] = None
) -> bool:
"""
Создать новую группу в конфигурации.
Args:
group_name: Имя группы.
chat_id: ID чата (может быть int или str).
messenger: Тип мессенджера (telegram, max).
thread_id: ID треда в группе (по умолчанию 0).
config: Дополнительная конфигурация для мессенджера (опционально).
Returns:
True если группа создана успешно, False если группа уже существует.
"""
# Загружаем текущую конфигурацию
if not self._is_cache_valid():
try:
self._cache = await self._load_config()
self._cache_time = datetime.now()
except (FileNotFoundError, json.JSONDecodeError):
# Если файл не существует, создаем новый
self._cache = {}
# Проверяем, существует ли группа
if group_name in self._cache:
logger.warning(f"Группа '{group_name}' уже существует")
return False
# Добавляем группу
self._cache[group_name] = {
"messenger": messenger,
"chat_id": chat_id,
"thread_id": thread_id,
"config": config or {}
}
await self._save_config(self._cache)
logger.info(f"Группа '{group_name}' создана с мессенджером '{messenger}' и ID {chat_id}")
return True
async def update_group(
self,
group_name: str,
chat_id: Optional[Union[int, str]] = None,
messenger: Optional[str] = None,
thread_id: Optional[int] = None,
config: Optional[Dict[str, Any]] = None
) -> bool:
"""
Обновить существующую группу в конфигурации.
Args:
group_name: Имя группы.
chat_id: Новый ID чата (опционально).
messenger: Новый тип мессенджера (опционально).
thread_id: Новый ID треда (опционально).
config: Новая дополнительная конфигурация (опционально).
Returns:
True если группа обновлена успешно, False если группа не найдена.
"""
# Загружаем текущую конфигурацию
if not self._is_cache_valid():
try:
self._cache = await self._load_config()
self._cache_time = datetime.now()
except (FileNotFoundError, json.JSONDecodeError):
logger.error("Не удалось загрузить конфигурацию групп")
return False
# Проверяем, существует ли группа
if group_name not in self._cache:
logger.warning(f"Группа '{group_name}' не найдена")
return False
# Обновляем группу (обновляем только указанные поля)
old_config = self._cache[group_name].copy()
if chat_id is not None:
self._cache[group_name]["chat_id"] = chat_id
if messenger is not None:
self._cache[group_name]["messenger"] = messenger
if thread_id is not None:
self._cache[group_name]["thread_id"] = thread_id
if config is not None:
self._cache[group_name]["config"] = config
await self._save_config(self._cache)
logger.info(f"Группа '{group_name}' обновлена: {old_config} -> {self._cache[group_name]}")
return True
async def delete_group(self, group_name: str) -> bool:
"""
Удалить группу из конфигурации.
Args:
group_name: Имя группы.
Returns:
True если группа удалена успешно, False если группа не найдена.
"""
# Загружаем текущую конфигурацию
if not self._is_cache_valid():
try:
self._cache = await self._load_config()
self._cache_time = datetime.now()
except (FileNotFoundError, json.JSONDecodeError):
logger.error("Не удалось загрузить конфигурацию групп")
return False
# Проверяем, существует ли группа
if group_name not in self._cache:
logger.warning(f"Группа '{group_name}' не найдена")
return False
# Удаляем группу
del self._cache[group_name]
await self._save_config(self._cache)
logger.info(f"Группа '{group_name}' удалена")
return True
# Глобальный экземпляр менеджера конфигурации (lazy initialization)
_groups_config_instance = None
def get_groups_config() -> GroupsConfig:
"""
Получить экземпляр менеджера конфигурации групп (lazy initialization).
Returns:
Экземпляр GroupsConfig.
"""
global _groups_config_instance
if _groups_config_instance is None:
_groups_config_instance = GroupsConfig()
return _groups_config_instance
# Глобальный экземпляр менеджера конфигурации (lazy initialization)
class _GroupsConfigProxy:
"""Прокси для ленивой инициализации groups_config."""
def __getattr__(self, name):
"""Получить атрибут из groups_config."""
return getattr(get_groups_config(), name)
groups_config = _GroupsConfigProxy()

236
app/core/jira_client.py Normal file
View File

@@ -0,0 +1,236 @@
"""
Клиент для работы с Jira API.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Optional, Dict, Any
from jira import JIRA
from jira.exceptions import JIRAError
logger = logging.getLogger(__name__)
class JiraClient:
"""Клиент для работы с Jira API."""
def __init__(
self,
url: str,
email: str,
api_token: str
):
"""
Инициализация клиента Jira.
Args:
url: URL Jira сервера.
email: Email пользователя Jira.
api_token: API токен Jira.
"""
self.url = url.rstrip('/')
self.email = email
self.api_token = api_token
self._client: Optional[JIRA] = None
def get_client(self) -> JIRA:
"""
Получить экземпляр клиента Jira (создается при первом обращении).
Returns:
Экземпляр JIRA клиента.
"""
if self._client is None:
try:
self._client = JIRA(
server=self.url,
basic_auth=(self.email, self.api_token)
)
logger.info(f"Jira клиент подключен к {self.url}")
except JIRAError as e:
logger.error(f"Ошибка подключения к Jira: {e}")
raise
return self._client
def create_issue(
self,
project: str,
summary: str,
description: str,
issue_type: str = "Bug",
assignee: Optional[str] = None,
priority: Optional[str] = None,
labels: Optional[list] = None,
components: Optional[list] = None
) -> Optional[str]:
"""
Создать тикет в Jira.
Args:
project: Ключ проекта Jira.
summary: Заголовок тикета.
description: Описание тикета.
issue_type: Тип задачи.
assignee: Email исполнителя (опционально).
priority: Приоритет задачи (опционально).
labels: Список меток (опционально).
components: Список компонентов (опционально).
Returns:
Ключ созданного тикета (например, "MON-123") или None в случае ошибки.
"""
try:
client = self.get_client()
# Формируем словарь для создания тикета
issue_dict = {
'project': {'key': project},
'summary': summary,
'description': description,
'issuetype': {'name': issue_type}
}
# Добавляем приоритет, если указан
if priority:
issue_dict['priority'] = {'name': priority}
# Добавляем метки, если указаны
if labels:
issue_dict['labels'] = labels
# Добавляем компоненты, если указаны
if components:
issue_dict['components'] = [{'name': comp} for comp in components]
# Создаем тикет
issue = client.create_issue(fields=issue_dict)
# Назначаем исполнителя, если указан
if assignee:
try:
# Пытаемся найти пользователя по email или username
users = client.search_users(query=assignee)
if users:
# Назначаем первого найденного пользователя
user_account_id = users[0].accountId
client.assign_issue(issue, user_account_id)
logger.info(f"Исполнитель {assignee} (accountId: {user_account_id}) назначен на тикет {issue.key}")
else:
logger.warning(f"Пользователь {assignee} не найден в Jira, тикет создан без исполнителя")
except JIRAError as e:
logger.error(f"Ошибка назначения исполнителя {assignee}: {e}")
except Exception as e:
logger.error(f"Неожиданная ошибка при назначении исполнителя: {e}")
logger.info(f"Тикет {issue.key} создан в Jira")
return issue.key
except JIRAError as e:
logger.error(f"Ошибка создания тикета в Jira: {e}")
return None
except Exception as e:
logger.error(f"Неожиданная ошибка при создании тикета: {e}")
return None
def get_issue_url(self, issue_key: str) -> str:
"""
Получить URL тикета в Jira.
Args:
issue_key: Ключ тикета (например, "MON-123").
Returns:
URL тикета в Jira.
"""
return f"{self.url}/browse/{issue_key}"
def update_issue(
self,
issue_key: str,
summary: Optional[str] = None,
description: Optional[str] = None,
assignee: Optional[str] = None,
priority: Optional[str] = None,
labels: Optional[list] = None
) -> bool:
"""
Обновить тикет в Jira.
Args:
issue_key: Ключ тикета.
summary: Новый заголовок (опционально).
description: Новое описание (опционально).
assignee: Новый исполнитель (опционально).
priority: Новый приоритет (опционально).
labels: Новые метки (опционально).
Returns:
True если тикет обновлен успешно, False в противном случае.
"""
try:
client = self.get_client()
issue = client.issue(issue_key)
update_dict = {}
if summary:
update_dict['summary'] = [{'set': summary}]
if description:
update_dict['description'] = [{'set': description}]
if priority:
update_dict['priority'] = [{'set': {'name': priority}}]
if labels:
update_dict['labels'] = [{'set': labels}]
if update_dict:
issue.update(fields=update_dict)
if assignee:
try:
users = client.search_users(query=assignee)
if users:
user_account_id = users[0].accountId
client.assign_issue(issue, user_account_id)
logger.info(f"Исполнитель {assignee} (accountId: {user_account_id}) назначен на тикет {issue_key}")
else:
logger.warning(f"Пользователь {assignee} не найден в Jira")
except JIRAError as e:
logger.error(f"Ошибка назначения исполнителя {assignee}: {e}")
except Exception as e:
logger.error(f"Неожиданная ошибка при назначении исполнителя: {e}")
logger.info(f"Тикет {issue_key} обновлен в Jira")
return True
except JIRAError as e:
logger.error(f"Ошибка обновления тикета {issue_key}: {e}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при обновлении тикета: {e}")
return False
def add_comment(self, issue_key: str, comment: str) -> bool:
"""
Добавить комментарий к тикету.
Args:
issue_key: Ключ тикета.
comment: Текст комментария.
Returns:
True если комментарий добавлен успешно, False в противном случае.
"""
try:
client = self.get_client()
issue = client.issue(issue_key)
issue.add_comment(comment)
logger.info(f"Комментарий добавлен к тикету {issue_key}")
return True
except JIRAError as e:
logger.error(f"Ошибка добавления комментария к тикету {issue_key}: {e}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при добавлении комментария: {e}")
return False

212
app/core/jira_mapping.py Normal file
View File

@@ -0,0 +1,212 @@
"""
Управление конфигурацией маппинга алертов в Jira тикеты.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import json
import logging
from typing import Dict, Any, Optional, List
import aiofiles
from datetime import datetime, timedelta
from app.models.jira import JiraMappingConfig, JiraSourceMapping, JiraMapping, JiraMappingCondition
from app.core.config import get_settings
logger = logging.getLogger(__name__)
class JiraMappingManager:
"""Менеджер конфигурации маппинга алертов в Jira тикеты с кэшированием."""
def __init__(self, config_path: Optional[str] = None):
"""
Инициализация менеджера конфигурации.
Args:
config_path: Путь к файлу конфигурации маппинга.
"""
settings = get_settings()
self.config_path = config_path or settings.jira_mapping_config_path
self._cache: Optional[JiraMappingConfig] = None
self._cache_time: Optional[datetime] = None
self._cache_ttl = timedelta(minutes=10) # Кэш на 10 минут
async def _load_config(self) -> JiraMappingConfig:
"""
Загрузить конфигурацию маппинга из файла.
Returns:
Конфигурация маппинга алертов в Jira тикеты.
Raises:
FileNotFoundError: Если файл конфигурации не найден.
json.JSONDecodeError: Если файл содержит некорректный JSON.
"""
try:
async with aiofiles.open(self.config_path, 'r', encoding='utf-8') as f:
content = await f.read()
config_dict = json.loads(content)
config = JiraMappingConfig(**config_dict)
logger.info(f"Конфигурация маппинга Jira загружена из {self.config_path}")
return config
except FileNotFoundError:
logger.warning(f"Файл конфигурации маппинга Jira не найден: {self.config_path}")
# Возвращаем пустую конфигурацию
return JiraMappingConfig()
except json.JSONDecodeError as e:
logger.error(f"Ошибка парсинга JSON в файле конфигурации маппинга: {e}")
return JiraMappingConfig()
except Exception as e:
logger.error(f"Ошибка загрузки конфигурации маппинга: {e}")
return JiraMappingConfig()
def _is_cache_valid(self) -> bool:
"""Проверить, валиден ли кэш."""
if self._cache is None or self._cache_time is None:
return False
return datetime.now() - self._cache_time < self._cache_ttl
async def get_config(self) -> JiraMappingConfig:
"""
Получить конфигурацию маппинга (с кэшированием).
Returns:
Конфигурация маппинга алертов в Jira тикеты.
"""
if not self._is_cache_valid():
self._cache = await self._load_config()
self._cache_time = datetime.now()
return self._cache
async def find_mapping(
self,
source: str,
alert_data: Dict[str, Any]
) -> Optional[JiraMapping]:
"""
Найти подходящий маппинг для алерта.
Args:
source: Источник алерта (alertmanager, grafana, zabbix).
alert_data: Данные алерта.
Returns:
Подходящий маппинг или None, если маппинг не найден.
"""
config = await self.get_config()
# Получаем конфигурацию для источника
source_mapping: Optional[JiraSourceMapping] = None
if source == "alertmanager" and config.alertmanager:
source_mapping = config.alertmanager
elif source == "grafana" and config.grafana:
source_mapping = config.grafana
elif source == "zabbix" and config.zabbix:
source_mapping = config.zabbix
if not source_mapping:
return None
# Ищем подходящий маппинг по условиям
for mapping in source_mapping.mappings:
if self._check_conditions(mapping.conditions, alert_data):
return mapping
# Если маппинг не найден, возвращаем дефолтный маппинг
return JiraMapping(
conditions=JiraMappingCondition(),
project=source_mapping.default_project,
assignee=source_mapping.default_assignee,
issue_type=source_mapping.default_issue_type,
priority=source_mapping.default_priority,
labels=[]
)
def _check_conditions(
self,
conditions: JiraMappingCondition,
alert_data: Dict[str, Any]
) -> bool:
"""
Проверить, соответствуют ли данные алерта условиям маппинга.
Args:
conditions: Условия маппинга.
alert_data: Данные алерта.
Returns:
True если условия выполнены, False в противном случае.
"""
# Проверяем severity
if conditions.severity:
if alert_data.get("severity") != conditions.severity:
return False
# Проверяем namespace
if conditions.namespace:
if alert_data.get("namespace") != conditions.namespace:
return False
# Проверяем state
if conditions.state:
if alert_data.get("state") != conditions.state:
return False
# Проверяем status
if conditions.status:
if alert_data.get("status") != conditions.status:
return False
# Проверяем event-severity
if conditions.event_severity:
if alert_data.get("event-severity") != conditions.event_severity:
return False
# Проверяем теги
if conditions.tags:
alert_tags = alert_data.get("tags", {})
for key, value in conditions.tags.items():
if alert_tags.get(key) != value:
return False
return True
async def refresh_cache(self) -> None:
"""Принудительно обновить кэш конфигурации."""
try:
self._cache = await self._load_config()
self._cache_time = datetime.now()
logger.info("Кэш конфигурации маппинга Jira обновлен")
except Exception as e:
logger.error(f"Ошибка обновления кэша: {e}")
# Глобальный экземпляр менеджера конфигурации маппинга (lazy initialization)
_jira_mapping_manager_instance = None
def get_jira_mapping_manager() -> JiraMappingManager:
"""
Получить экземпляр менеджера конфигурации маппинга Jira (lazy initialization).
Returns:
Экземпляр JiraMappingManager.
"""
global _jira_mapping_manager_instance
if _jira_mapping_manager_instance is None:
_jira_mapping_manager_instance = JiraMappingManager()
return _jira_mapping_manager_instance
# Для обратной совместимости (lazy initialization)
class _JiraMappingManagerProxy:
"""Прокси для ленивой инициализации jira_mapping_manager."""
def __getattr__(self, name):
"""Получить атрибут из jira_mapping_manager."""
return getattr(get_jira_mapping_manager(), name)
jira_mapping_manager = _JiraMappingManagerProxy()

330
app/core/jira_utils.py Normal file
View File

@@ -0,0 +1,330 @@
"""
Утилиты для работы с Jira тикетами.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Optional, Dict, Any
from jinja2 import Environment, FileSystemLoader
from app.models.alertmanager import PrometheusAlert
from app.models.grafana import GrafanaAlert
from app.models.zabbix import ZabbixAlert
from app.models.jira import JiraMapping
from app.core.jira_client import JiraClient
from app.core.jira_mapping import jira_mapping_manager
from app.core.config import get_settings
from app.core.utils import add_spaces_to_alert_name
logger = logging.getLogger(__name__)
async def create_jira_ticket_from_alert(
alert: Any,
source: str,
k8s_cluster: Optional[str] = None,
mapping: Optional[JiraMapping] = None
) -> Optional[str]:
"""
Создать Jira тикет на основе алерта.
Args:
alert: Данные алерта (PrometheusAlert, GrafanaAlert, ZabbixAlert).
source: Источник алерта (alertmanager, grafana, zabbix).
k8s_cluster: Имя Kubernetes кластера (опционально).
mapping: Маппинг для создания тикета (опционально).
Returns:
Ключ созданного тикета или None в случае ошибки.
"""
from app.core.config import get_settings
settings = get_settings()
if not settings.jira_enabled:
logger.debug("Jira отключен, тикет не создается")
return None
if not settings.jira_url or not settings.jira_email or not settings.jira_api_token:
logger.warning("Jira не настроен (отсутствуют URL, email или API token)")
return None
# Создаем клиент Jira
try:
jira_client = JiraClient(
url=settings.jira_url,
email=settings.jira_email,
api_token=settings.jira_api_token
)
except Exception as e:
logger.error(f"Ошибка создания Jira клиента: {e}")
return None
# Получаем маппинг, если не указан
if not mapping:
alert_data = _extract_alert_data(alert, source, k8s_cluster)
mapping = await jira_mapping_manager.find_mapping(source, alert_data)
if not mapping:
logger.warning(f"Маппинг не найден для источника {source}")
return None
# Формируем данные тикета
summary = _generate_jira_summary(alert, source, mapping)
description = _generate_jira_description(alert, source, k8s_cluster, mapping)
# Создаем тикет
try:
issue_key = jira_client.create_issue(
project=mapping.project,
summary=summary,
description=description,
issue_type=mapping.issue_type,
assignee=mapping.assignee,
priority=mapping.priority,
labels=mapping.labels
)
if issue_key:
logger.info(f"Jira тикет {issue_key} создан для алерта из {source}")
# Увеличиваем счетчик созданных тикетов
from app.core.metrics import metrics
metrics.increment_jira_ticket_created(
source=source,
project=mapping.project,
k8s_cluster=k8s_cluster or "",
chat="",
thread=0
)
return issue_key
except Exception as e:
logger.error(f"Ошибка создания Jira тикета: {e}")
# Увеличиваем счетчик ошибок
from app.core.metrics import metrics
metrics.increment_jira_ticket_error(
source=source,
k8s_cluster=k8s_cluster or "",
chat="",
thread=0
)
return None
def _extract_alert_data(
alert: Any,
source: str,
k8s_cluster: Optional[str] = None
) -> Dict[str, Any]:
"""
Извлечь данные алерта для маппинга.
Args:
alert: Данные алерта.
source: Источник алерта.
k8s_cluster: Имя Kubernetes кластера.
Returns:
Словарь с данными алерта.
"""
alert_data = {}
if source == "alertmanager" and isinstance(alert, PrometheusAlert):
alert_data["status"] = alert.status
alert_data.update(alert.commonLabels)
alert_data.update(alert.commonAnnotations)
if k8s_cluster:
alert_data["k8s_cluster"] = k8s_cluster
elif source == "grafana" and isinstance(alert, GrafanaAlert):
alert_data["state"] = alert.state
alert_data["tags"] = alert.tags
alert_data["ruleName"] = alert.ruleName
elif source == "zabbix" and isinstance(alert, ZabbixAlert):
alert_data["status"] = alert.status
alert_data["event-severity"] = alert.event_severity or ""
alert_data["event-name"] = alert.event_name
alert_data["host-name"] = alert.host_name
return alert_data
def _generate_jira_summary(
alert: Any,
source: str,
mapping: JiraMapping
) -> str:
"""
Сгенерировать заголовок Jira тикета.
Args:
alert: Данные алерта.
source: Источник алерта.
mapping: Маппинг для создания тикета.
Returns:
Заголовок тикета.
"""
severity_prefix = ""
alert_name = ""
if source == "alertmanager" and isinstance(alert, PrometheusAlert):
severity = alert.commonLabels.get("severity", "")
alert_name = alert.commonLabels.get("alertname", "")
if severity:
severity_prefix = f"[{severity.upper()}] "
if alert_name:
alert_name = add_spaces_to_alert_name(alert_name)
summary = alert.commonAnnotations.get("summary", alert_name)
elif source == "grafana" and isinstance(alert, GrafanaAlert):
alert_name = alert.ruleName
if alert.state == "alerting":
severity_prefix = "[ALERTING] "
summary = alert.title or alert_name
elif source == "zabbix" and isinstance(alert, ZabbixAlert):
severity = alert.event_severity or ""
alert_name = alert.event_name
if severity:
severity_prefix = f"[{severity.upper()}] "
summary = alert.alert_subject or alert_name
else:
summary = "Unknown Alert"
return f"{severity_prefix}{alert_name}: {summary}"[:255] # Ограничение Jira
def _generate_jira_description(
alert: Any,
source: str,
k8s_cluster: Optional[str] = None,
mapping: Optional[JiraMapping] = None
) -> str:
"""
Сгенерировать описание Jira тикета.
Args:
alert: Данные алерта.
source: Источник алерта.
k8s_cluster: Имя Kubernetes кластера.
mapping: Маппинг для создания тикета.
Returns:
Описание тикета в формате Markdown.
"""
from app.core.config import get_settings
settings = get_settings()
# Загружаем шаблон описания
try:
environment = Environment(loader=FileSystemLoader(settings.templates_path))
template_name = f"jira_{source}.tmpl"
try:
template = environment.get_template(template_name)
except Exception:
# Если шаблон не найден, используем общий шаблон
template = environment.get_template("jira_common.tmpl")
except Exception:
# Если общий шаблон не найден, используем простое описание
return _generate_simple_description(alert, source, k8s_cluster)
# Формируем словарь для шаблона
template_data = _prepare_template_data(alert, source, k8s_cluster)
# Рендерим шаблон
description = template.render(template_data)
return description
def _generate_simple_description(
alert: Any,
source: str,
k8s_cluster: Optional[str] = None
) -> str:
"""
Сгенерировать простое описание тикета без шаблона.
Args:
alert: Данные алерта.
source: Источник алерта.
k8s_cluster: Имя Kubernetes кластера.
Returns:
Простое описание тикета.
"""
description = f"**Источник:** {source}\n\n"
if source == "alertmanager" and isinstance(alert, PrometheusAlert):
description += f"**Статус:** {alert.status}\n\n"
description += "**Метки:**\n"
for key, value in alert.commonLabels.items():
description += f"- {key}: {value}\n"
description += "\n**Аннотации:**\n"
for key, value in alert.commonAnnotations.items():
description += f"- {key}: {value}\n"
if k8s_cluster:
description += f"\n**Kubernetes кластер:** {k8s_cluster}\n"
elif source == "grafana" and isinstance(alert, GrafanaAlert):
description += f"**Состояние:** {alert.state}\n\n"
description += f"**Правило:** {alert.ruleName}\n\n"
description += f"**Сообщение:** {alert.message or 'Нет сообщения'}\n\n"
if alert.tags:
description += "**Теги:**\n"
for key, value in alert.tags.items():
description += f"- {key}: {value}\n"
elif source == "zabbix" and isinstance(alert, ZabbixAlert):
description += f"**Статус:** {alert.status}\n\n"
description += f"**Серьезность:** {alert.event_severity or 'Unknown'}\n\n"
description += f"**Событие:** {alert.event_name}\n\n"
description += f"**Хост:** {alert.host_name} ({alert.host_ip})\n\n"
description += f"**Сообщение:** {alert.alert_message}\n"
return description
def _prepare_template_data(
alert: Any,
source: str,
k8s_cluster: Optional[str] = None
) -> Dict[str, Any]:
"""
Подготовить данные для шаблона описания тикета.
Args:
alert: Данные алерта.
source: Источник алерта.
k8s_cluster: Имя Kubernetes кластера.
Returns:
Словарь с данными для шаблона.
"""
template_data = {
"source": source,
"k8s_cluster": k8s_cluster or "",
}
if source == "alertmanager" and isinstance(alert, PrometheusAlert):
template_data["status"] = alert.status
template_data["common_labels"] = alert.commonLabels
template_data["common_annotations"] = alert.commonAnnotations
template_data["alertname"] = alert.commonLabels.get("alertname", "")
template_data["severity"] = alert.commonLabels.get("severity", "")
template_data["summary"] = alert.commonAnnotations.get("summary", "")
template_data["description"] = alert.commonAnnotations.get("description", "")
elif source == "grafana" and isinstance(alert, GrafanaAlert):
template_data["state"] = alert.state
template_data["title"] = alert.title
template_data["ruleName"] = alert.ruleName
template_data["message"] = alert.message or ""
template_data["tags"] = alert.tags
template_data["evalMatches"] = alert.evalMatches
elif source == "zabbix" and isinstance(alert, ZabbixAlert):
template_data["status"] = alert.status
template_data["event_severity"] = alert.event_severity or ""
template_data["event_name"] = alert.event_name
template_data["alert_subject"] = alert.alert_subject
template_data["alert_message"] = alert.alert_message
template_data["host_name"] = alert.host_name
template_data["host_ip"] = alert.host_ip
template_data["host_port"] = alert.host_port
return template_data

View File

@@ -0,0 +1,102 @@
"""
Фабрика для создания клиентов мессенджеров.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Optional, Dict, Any
from app.core.messengers.base import MessengerClient
from app.core.messengers.telegram import TelegramMessengerClient
from app.core.messengers.max import MaxMessengerClient
from app.core.config import get_settings
logger = logging.getLogger(__name__)
class MessengerFactory:
"""Фабрика для создания клиентов мессенджеров."""
@staticmethod
def create(messenger_type: str, **kwargs) -> MessengerClient:
"""
Создать клиент мессенджера.
Args:
messenger_type: Тип мессенджера (telegram, max).
**kwargs: Дополнительные параметры для инициализации клиента.
Returns:
Экземпляр MessengerClient.
Raises:
ValueError: Если тип мессенджера неизвестен.
"""
messenger_type = messenger_type.lower()
if messenger_type == "telegram":
bot_token = kwargs.get("bot_token")
return TelegramMessengerClient(bot_token=bot_token)
elif messenger_type == "max":
access_token = kwargs.get("access_token")
api_version = kwargs.get("api_version", "5.131")
return MaxMessengerClient(access_token=access_token, api_version=api_version)
else:
raise ValueError(f"Неизвестный тип мессенджера: {messenger_type}")
@staticmethod
def create_from_config(group_config: Dict[str, Any]) -> MessengerClient:
"""
Создать клиент мессенджера из конфигурации группы.
Args:
group_config: Конфигурация группы с полями:
- messenger: Тип мессенджера (telegram, max)
- config: Дополнительная конфигурация для мессенджера
Returns:
Экземпляр MessengerClient.
"""
messenger_type = group_config.get("messenger", "telegram")
config = group_config.get("config", {})
# Если конфигурация не указана, используем настройки из переменных окружения
settings = get_settings()
if messenger_type == "telegram":
bot_token = config.get("bot_token") or settings.telegram_bot_token
return TelegramMessengerClient(bot_token=bot_token)
elif messenger_type == "max":
access_token = config.get("access_token") or settings.max_access_token
api_version = config.get("api_version", settings.max_api_version or "5.131")
return MaxMessengerClient(access_token=access_token, api_version=api_version)
else:
raise ValueError(f"Неизвестный тип мессенджера: {messenger_type}")
# Глобальный кэш клиентов мессенджеров
_messenger_clients_cache: Dict[str, MessengerClient] = {}
def get_messenger_client(messenger_type: str, **kwargs) -> MessengerClient:
"""
Получить клиент мессенджера (с кэшированием).
Args:
messenger_type: Тип мессенджера (telegram, max).
**kwargs: Дополнительные параметры для инициализации клиента.
Returns:
Экземпляр MessengerClient.
"""
# Для Telegram используем глобальный экземпляр, если bot_token не указан
if messenger_type == "telegram" and "bot_token" not in kwargs:
cache_key = "telegram_default"
if cache_key not in _messenger_clients_cache:
_messenger_clients_cache[cache_key] = MessengerFactory.create(messenger_type, **kwargs)
return _messenger_clients_cache[cache_key]
# Для других мессенджеров создаем новый экземпляр
return MessengerFactory.create(messenger_type, **kwargs)

View File

@@ -0,0 +1,16 @@
"""
Модуль для работы с различными мессенджерами.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from app.core.messengers.base import MessengerClient
from app.core.messengers.telegram import TelegramMessengerClient
from app.core.messengers.max import MaxMessengerClient
__all__ = [
"MessengerClient",
"TelegramMessengerClient",
"MaxMessengerClient",
]

174
app/core/messengers/base.py Normal file
View File

@@ -0,0 +1,174 @@
"""
Базовый абстрактный класс для работы с мессенджерами.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from abc import ABC, abstractmethod
from typing import Optional, Union, Dict, Any
logger = logging.getLogger(__name__)
class MessengerClient(ABC):
"""Базовый абстрактный класс для всех мессенджеров."""
@abstractmethod
async def send_message(
self,
chat_id: Union[str, int],
text: str,
thread_id: Optional[int] = None,
reply_markup: Optional[Dict[str, Any]] = None,
disable_web_page_preview: bool = True,
parse_mode: str = "HTML",
**kwargs
) -> bool:
"""
Отправить текстовое сообщение.
Args:
chat_id: ID чата или группы (может быть строкой или числом).
text: Текст сообщения.
thread_id: ID треда в группе (опционально, не все мессенджеры поддерживают).
reply_markup: Клавиатура с кнопками (опционально, формат зависит от мессенджера).
disable_web_page_preview: Отключить превью ссылок.
parse_mode: Режим парсинга (HTML, Markdown, и т.д.).
**kwargs: Дополнительные параметры для конкретного мессенджера.
Returns:
True если сообщение отправлено успешно, False в противном случае.
"""
pass
@abstractmethod
async def send_photo(
self,
chat_id: Union[str, int],
photo: Union[str, bytes],
caption: Optional[str] = None,
thread_id: Optional[int] = None,
parse_mode: str = "HTML",
**kwargs
) -> bool:
"""
Отправить фото.
Args:
chat_id: ID чата или группы.
photo: Путь к файлу, URL, bytes или BytesIO объект с фото.
caption: Подпись к фото (опционально).
thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
**kwargs: Дополнительные параметры.
Returns:
True если фото отправлено успешно, False в противном случае.
"""
pass
@abstractmethod
async def send_video(
self,
chat_id: Union[str, int],
video: Union[str, bytes],
caption: Optional[str] = None,
thread_id: Optional[int] = None,
parse_mode: str = "HTML",
duration: Optional[int] = None,
width: Optional[int] = None,
height: Optional[int] = None,
**kwargs
) -> bool:
"""
Отправить видео.
Args:
chat_id: ID чата или группы.
video: Путь к файлу, URL, bytes или BytesIO объект с видео.
caption: Подпись к видео (опционально).
thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
duration: Длительность видео в секундах (опционально).
width: Ширина видео (опционально).
height: Высота видео (опционально).
**kwargs: Дополнительные параметры.
Returns:
True если видео отправлено успешно, False в противном случае.
"""
pass
@abstractmethod
async def send_audio(
self,
chat_id: Union[str, int],
audio: Union[str, bytes],
caption: Optional[str] = None,
thread_id: Optional[int] = None,
parse_mode: str = "HTML",
duration: Optional[int] = None,
performer: Optional[str] = None,
title: Optional[str] = None,
**kwargs
) -> bool:
"""
Отправить аудио.
Args:
chat_id: ID чата или группы.
audio: Путь к файлу, URL, bytes или BytesIO объект с аудио.
caption: Подпись к аудио (опционально).
thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
duration: Длительность аудио в секундах (опционально).
performer: Исполнитель (опционально).
title: Название трека (опционально).
**kwargs: Дополнительные параметры.
Returns:
True если аудио отправлено успешно, False в противном случае.
"""
pass
@abstractmethod
async def send_document(
self,
chat_id: Union[str, int],
document: Union[str, bytes],
caption: Optional[str] = None,
thread_id: Optional[int] = None,
parse_mode: str = "HTML",
filename: Optional[str] = None,
**kwargs
) -> bool:
"""
Отправить документ.
Args:
chat_id: ID чата или группы.
document: Путь к файлу, URL, bytes или BytesIO объект с документом.
caption: Подпись к документу (опционально).
thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
filename: Имя файла (опционально).
**kwargs: Дополнительные параметры.
Returns:
True если документ отправлен успешно, False в противном случае.
"""
pass
@property
@abstractmethod
def messenger_type(self) -> str:
"""Тип мессенджера (telegram, max, и т.д.)."""
pass
@property
@abstractmethod
def supports_threads(self) -> bool:
"""Поддерживает ли мессенджер треды."""
pass

476
app/core/messengers/max.py Normal file
View File

@@ -0,0 +1,476 @@
"""
Адаптер для работы с MAX/VK через MessengerClient интерфейс.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
import io
from typing import Optional, Union, Dict, Any
import httpx
from app.core.messengers.base import MessengerClient
logger = logging.getLogger(__name__)
class MaxMessengerClient(MessengerClient):
"""Адаптер для MAX/VK, реализующий интерфейс MessengerClient."""
def __init__(self, access_token: str, api_version: str = "5.131"):
"""
Инициализация клиента MAX/VK.
Args:
access_token: Access token для VK API.
api_version: Версия VK API (по умолчанию 5.131).
Raises:
ValueError: Если access_token не указан.
"""
if not access_token:
raise ValueError("MAX_ACCESS_TOKEN не установлен")
self.access_token = access_token
self.api_version = api_version
self.api_url = "https://api.vk.com/method"
@property
def messenger_type(self) -> str:
"""Тип мессенджера."""
return "max"
@property
def supports_threads(self) -> bool:
"""MAX/VK не поддерживает треды."""
return False
def _convert_html_to_vk_format(self, text: str) -> str:
"""
Конвертировать HTML в формат VK.
VK поддерживает свою разметку:
- [bold]текст[/bold] - жирный
- [italic]текст[/italic] - курсив
- [code]текст[/code] - код
Args:
text: Текст с HTML разметкой.
Returns:
Текст с VK разметкой.
"""
# Простая конвертация HTML в VK формат
# Заменяем <b> и </b> на [bold] и [/bold]
text = text.replace("<b>", "[bold]").replace("</b>", "[/bold]")
text = text.replace("<strong>", "[bold]").replace("</strong>", "[/bold]")
# Заменяем <i> и </i> на [italic] и [/italic]
text = text.replace("<i>", "[italic]").replace("</i>", "[/italic]")
text = text.replace("<em>", "[italic]").replace("</em>", "[/italic]")
# Заменяем <code> и </code> на [code] и [/code]
text = text.replace("<code>", "[code]").replace("</code>", "[/code]")
# Заменяем <pre> и </pre> на [code] и [/code]
text = text.replace("<pre>", "[code]").replace("</pre>", "[/code]")
# Заменяем <br> и <br/> на перенос строки
text = text.replace("<br>", "\n").replace("<br/>", "\n").replace("<br />", "\n")
# Удаляем другие HTML теги (простая очистка)
import re
text = re.sub(r'<[^>]+>', '', text)
return text
async def _download_file(self, url: str) -> bytes:
"""
Скачать файл по URL.
Args:
url: URL файла.
Returns:
Байты файла.
"""
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
return response.content
async def _upload_photo_to_vk(self, photo: Union[str, bytes], peer_id: Union[str, int]) -> Optional[str]:
"""
Загрузить фото в VK и получить attachment.
Args:
photo: URL или bytes фото.
peer_id: ID получателя.
Returns:
Attachment string для VK API или None в случае ошибки.
"""
try:
# Если photo - это URL, скачиваем файл
if isinstance(photo, str) and (photo.startswith('http://') or photo.startswith('https://')):
photo_bytes = await self._download_file(photo)
elif isinstance(photo, bytes):
photo_bytes = photo
else:
logger.error(f"Неподдерживаемый тип photo: {type(photo)}")
return None
# Получаем URL для загрузки фото
async with httpx.AsyncClient() as client:
# Шаг 1: Получаем upload server
upload_url_params = {
"access_token": self.access_token,
"peer_id": peer_id,
"v": self.api_version
}
upload_url_response = await client.get(
f"{self.api_url}/photos.getMessagesUploadServer",
params=upload_url_params
)
upload_url_data = upload_url_response.json()
if "error" in upload_url_data:
logger.error(f"Ошибка получения upload server: {upload_url_data['error']}")
return None
upload_url = upload_url_data["response"]["upload_url"]
# Шаг 2: Загружаем фото
files = {"photo": ("photo.jpg", photo_bytes, "image/jpeg")}
upload_response = await client.post(upload_url, files=files)
upload_data = upload_response.json()
if "error" in upload_data:
logger.error(f"Ошибка загрузки фото: {upload_data['error']}")
return None
# Шаг 3: Сохраняем фото
save_params = {
"access_token": self.access_token,
"server": upload_data["server"],
"photo": upload_data["photo"],
"hash": upload_data["hash"],
"v": self.api_version
}
save_response = await client.get(
f"{self.api_url}/photos.saveMessagesPhoto",
params=save_params
)
save_data = save_response.json()
if "error" in save_data:
logger.error(f"Ошибка сохранения фото: {save_data['error']}")
return None
# Формируем attachment string
photo_data = save_data["response"][0]
attachment = f"photo{photo_data['owner_id']}_{photo_data['id']}"
return attachment
except Exception as e:
logger.error(f"Ошибка загрузки фото в VK: {e}")
return None
async def send_message(
self,
chat_id: Union[str, int],
text: str,
thread_id: Optional[int] = None,
reply_markup: Optional[Dict[str, Any]] = None,
disable_web_page_preview: bool = True,
parse_mode: str = "HTML",
**kwargs
) -> bool:
"""
Отправить текстовое сообщение в MAX/VK.
Args:
chat_id: ID чата или группы (может быть строкой или числом).
text: Текст сообщения.
thread_id: ID треда в группе (игнорируется для VK).
reply_markup: Клавиатура с кнопками (опционально, формат VK).
disable_web_page_preview: Отключить превью ссылок (игнорируется для VK).
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
**kwargs: Дополнительные параметры (attachment, и т.д.).
Returns:
True если сообщение отправлено успешно, False в противном случае.
"""
if thread_id is not None:
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
try:
# Конвертируем HTML в формат VK
if parse_mode == "HTML":
text = self._convert_html_to_vk_format(text)
# Преобразуем chat_id в int
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
# Генерируем random_id для VK API (должен быть уникальным для каждого сообщения)
import random
random_id = random.randint(1, 2**31 - 1)
# Параметры для отправки сообщения
params = {
"access_token": self.access_token,
"peer_id": peer_id,
"message": text,
"v": self.api_version,
"random_id": random_id # VK требует random_id
}
# Добавляем attachment, если есть
if "attachment" in kwargs:
params["attachment"] = kwargs["attachment"]
# Добавляем клавиатуру, если есть
if reply_markup:
import json
params["keyboard"] = json.dumps(reply_markup)
# Отправляем сообщение
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.api_url}/messages.send",
params=params
)
response_data = response.json()
if "error" in response_data:
error = response_data["error"]
logger.error(f"Ошибка отправки сообщения в VK: {error}")
return False
message_id = response_data.get("response")
if message_id:
logger.info(f"Сообщение отправлено в VK чат {peer_id}, message_id: {message_id}")
return True
else:
logger.error("Не удалось получить message_id из ответа VK API")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке сообщения в VK: {e}")
return False
async def send_photo(
self,
chat_id: Union[str, int],
photo: Union[str, bytes],
caption: Optional[str] = None,
thread_id: Optional[int] = None,
parse_mode: str = "HTML",
**kwargs
) -> bool:
"""
Отправить фото в MAX/VK.
Args:
chat_id: ID чата или группы.
photo: URL или bytes фото.
caption: Подпись к фото (опционально).
thread_id: ID треда в группе (игнорируется для VK).
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
**kwargs: Дополнительные параметры.
Returns:
True если фото отправлено успешно, False в противном случае.
"""
if thread_id is not None:
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
try:
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
# Загружаем фото в VK
attachment = await self._upload_photo_to_vk(photo, peer_id)
if not attachment:
logger.error("Не удалось загрузить фото в VK")
return False
# Формируем текст сообщения
text = caption or ""
if parse_mode == "HTML" and text:
text = self._convert_html_to_vk_format(text)
# Отправляем сообщение с фото
return await self.send_message(
chat_id=peer_id,
text=text,
attachment=attachment,
parse_mode=parse_mode
)
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке фото в VK: {e}")
return False
async def send_video(
self,
chat_id: Union[str, int],
video: Union[str, bytes],
caption: Optional[str] = None,
thread_id: Optional[int] = None,
parse_mode: str = "HTML",
duration: Optional[int] = None,
width: Optional[int] = None,
height: Optional[int] = None,
**kwargs
) -> bool:
"""
Отправить видео в MAX/VK.
Примечание: VK API требует более сложную логику для загрузки видео.
В текущей реализации отправляется только ссылка на видео.
Args:
chat_id: ID чата или группы.
video: URL или bytes видео.
caption: Подпись к видео (опционально).
thread_id: ID треда в группе (игнорируется для VK).
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
duration: Длительность видео в секундах (игнорируется для VK).
width: Ширина видео (игнорируется для VK).
height: Высота видео (игнорируется для VK).
**kwargs: Дополнительные параметры.
Returns:
True если видео отправлено успешно, False в противном случае.
"""
if thread_id is not None:
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
try:
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
# Если video - это URL, отправляем как ссылку
if isinstance(video, str) and (video.startswith('http://') or video.startswith('https://')):
text = caption or video
if parse_mode == "HTML" and text:
text = self._convert_html_to_vk_format(text)
return await self.send_message(
chat_id=peer_id,
text=text,
parse_mode=parse_mode
)
else:
logger.warning("Загрузка видео через bytes пока не поддерживается в VK адаптере")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке видео в VK: {e}")
return False
async def send_audio(
self,
chat_id: Union[str, int],
audio: Union[str, bytes],
caption: Optional[str] = None,
thread_id: Optional[int] = None,
parse_mode: str = "HTML",
duration: Optional[int] = None,
performer: Optional[str] = None,
title: Optional[str] = None,
**kwargs
) -> bool:
"""
Отправить аудио в MAX/VK.
Примечание: VK API требует специальную логику для загрузки аудио.
В текущей реализации отправляется только ссылка на аудио.
Args:
chat_id: ID чата или группы.
audio: URL или bytes аудио.
caption: Подпись к аудио (опционально).
thread_id: ID треда в группе (игнорируется для VK).
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
duration: Длительность аудио в секундах (игнорируется для VK).
performer: Исполнитель (игнорируется для VK).
title: Название трека (игнорируется для VK).
**kwargs: Дополнительные параметры.
Returns:
True если аудио отправлено успешно, False в противном случае.
"""
if thread_id is not None:
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
try:
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
# Если audio - это URL, отправляем как ссылку
if isinstance(audio, str) and (audio.startswith('http://') or audio.startswith('https://')):
text = caption or audio
if parse_mode == "HTML" and text:
text = self._convert_html_to_vk_format(text)
return await self.send_message(
chat_id=peer_id,
text=text,
parse_mode=parse_mode
)
else:
logger.warning("Загрузка аудио через bytes пока не поддерживается в VK адаптере")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке аудио в VK: {e}")
return False
async def send_document(
self,
chat_id: Union[str, int],
document: Union[str, bytes],
caption: Optional[str] = None,
thread_id: Optional[int] = None,
parse_mode: str = "HTML",
filename: Optional[str] = None,
**kwargs
) -> bool:
"""
Отправить документ в MAX/VK.
Примечание: VK API требует специальную логику для загрузки документов.
В текущей реализации отправляется только ссылка на документ.
Args:
chat_id: ID чата или группы.
document: URL или bytes документа.
caption: Подпись к документу (опционально).
thread_id: ID треда в группе (игнорируется для VK).
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
filename: Имя файла (игнорируется для VK).
**kwargs: Дополнительные параметры.
Returns:
True если документ отправлен успешно, False в противном случае.
"""
if thread_id is not None:
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
try:
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
# Если document - это URL, отправляем как ссылку
if isinstance(document, str) and (document.startswith('http://') or document.startswith('https://')):
text = caption or document
if parse_mode == "HTML" and text:
text = self._convert_html_to_vk_format(text)
return await self.send_message(
chat_id=peer_id,
text=text,
parse_mode=parse_mode
)
else:
logger.warning("Загрузка документов через bytes пока не поддерживается в VK адаптере")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке документа в VK: {e}")
return False

View File

@@ -0,0 +1,255 @@
"""
Адаптер для работы с Telegram через MessengerClient интерфейс.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
import io
from typing import Optional, Union, Dict, Any
from telegram import InlineKeyboardMarkup
from app.core.messengers.base import MessengerClient
from app.core.telegram_client import TelegramClient
from app.core.button_utils import convert_dict_to_telegram_buttons
logger = logging.getLogger(__name__)
class TelegramMessengerClient(MessengerClient):
"""Адаптер для Telegram, реализующий интерфейс MessengerClient."""
def __init__(self, bot_token: Optional[str] = None):
"""
Инициализация клиента Telegram.
Args:
bot_token: Токен бота Telegram. Если не указан, используется из настроек.
"""
self._client = TelegramClient(bot_token=bot_token)
@property
def messenger_type(self) -> str:
"""Тип мессенджера."""
return "telegram"
@property
def supports_threads(self) -> bool:
"""Telegram поддерживает треды."""
return True
async def send_message(
self,
chat_id: Union[str, int],
text: str,
thread_id: Optional[int] = None,
reply_markup: Optional[Dict[str, Any]] = None,
disable_web_page_preview: bool = True,
parse_mode: str = "HTML",
**kwargs
) -> bool:
"""
Отправить текстовое сообщение в Telegram.
Args:
chat_id: ID чата или группы (преобразуется в int).
text: Текст сообщения.
thread_id: ID треда в группе (опционально).
reply_markup: Клавиатура с кнопками (опционально).
disable_web_page_preview: Отключить превью ссылок.
parse_mode: Режим парсинга (HTML, Markdown).
**kwargs: Дополнительные параметры (игнорируются).
Returns:
True если сообщение отправлено успешно, False в противном случае.
"""
# Преобразуем chat_id в int
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
# Преобразуем reply_markup из Dict в InlineKeyboardMarkup, если нужно
telegram_reply_markup = None
if reply_markup:
if isinstance(reply_markup, InlineKeyboardMarkup):
telegram_reply_markup = reply_markup
elif isinstance(reply_markup, dict):
# Преобразуем словарь в InlineKeyboardMarkup
telegram_reply_markup = convert_dict_to_telegram_buttons(reply_markup)
return await self._client.send_message(
chat_id=chat_id_int,
text=text,
message_thread_id=thread_id,
reply_markup=telegram_reply_markup,
disable_web_page_preview=disable_web_page_preview,
parse_mode=parse_mode
)
async def send_photo(
self,
chat_id: Union[str, int],
photo: Union[str, bytes],
caption: Optional[str] = None,
thread_id: Optional[int] = None,
parse_mode: str = "HTML",
**kwargs
) -> bool:
"""
Отправить фото в Telegram.
Args:
chat_id: ID чата или группы (преобразуется в int).
photo: Путь к файлу, URL, bytes или BytesIO объект с фото.
caption: Подпись к фото (опционально).
thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
**kwargs: Дополнительные параметры (игнорируются).
Returns:
True если фото отправлено успешно, False в противном случае.
"""
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
# Преобразуем bytes в BytesIO, если нужно
if isinstance(photo, bytes):
photo = io.BytesIO(photo)
return await self._client.send_photo(
chat_id=chat_id_int,
photo=photo,
caption=caption,
message_thread_id=thread_id,
parse_mode=parse_mode
)
async def send_video(
self,
chat_id: Union[str, int],
video: Union[str, bytes],
caption: Optional[str] = None,
thread_id: Optional[int] = None,
parse_mode: str = "HTML",
duration: Optional[int] = None,
width: Optional[int] = None,
height: Optional[int] = None,
**kwargs
) -> bool:
"""
Отправить видео в Telegram.
Args:
chat_id: ID чата или группы (преобразуется в int).
video: Путь к файлу, URL, bytes или BytesIO объект с видео.
caption: Подпись к видео (опционально).
thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
duration: Длительность видео в секундах (опционально).
width: Ширина видео (опционально).
height: Высота видео (опционально).
**kwargs: Дополнительные параметры (игнорируются).
Returns:
True если видео отправлено успешно, False в противном случае.
"""
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
# Преобразуем bytes в BytesIO, если нужно
if isinstance(video, bytes):
video = io.BytesIO(video)
return await self._client.send_video(
chat_id=chat_id_int,
video=video,
caption=caption,
message_thread_id=thread_id,
parse_mode=parse_mode,
duration=duration,
width=width,
height=height
)
async def send_audio(
self,
chat_id: Union[str, int],
audio: Union[str, bytes],
caption: Optional[str] = None,
thread_id: Optional[int] = None,
parse_mode: str = "HTML",
duration: Optional[int] = None,
performer: Optional[str] = None,
title: Optional[str] = None,
**kwargs
) -> bool:
"""
Отправить аудио в Telegram.
Args:
chat_id: ID чата или группы (преобразуется в int).
audio: Путь к файлу, URL, bytes или BytesIO объект с аудио.
caption: Подпись к аудио (опционально).
thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
duration: Длительность аудио в секундах (опционально).
performer: Исполнитель (опционально).
title: Название трека (опционально).
**kwargs: Дополнительные параметры (игнорируются).
Returns:
True если аудио отправлено успешно, False в противном случае.
"""
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
# Преобразуем bytes в BytesIO, если нужно
if isinstance(audio, bytes):
audio = io.BytesIO(audio)
return await self._client.send_audio(
chat_id=chat_id_int,
audio=audio,
caption=caption,
message_thread_id=thread_id,
parse_mode=parse_mode,
duration=duration,
performer=performer,
title=title
)
async def send_document(
self,
chat_id: Union[str, int],
document: Union[str, bytes],
caption: Optional[str] = None,
thread_id: Optional[int] = None,
parse_mode: str = "HTML",
filename: Optional[str] = None,
**kwargs
) -> bool:
"""
Отправить документ в Telegram.
Args:
chat_id: ID чата или группы (преобразуется в int).
document: Путь к файлу, URL, bytes или BytesIO объект с документом.
caption: Подпись к документу (опционально).
thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
filename: Имя файла (опционально).
**kwargs: Дополнительные параметры (игнорируются).
Returns:
True если документ отправлен успешно, False в противном случае.
"""
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
# Преобразуем bytes в BytesIO, если нужно
if isinstance(document, bytes):
document = io.BytesIO(document)
return await self._client.send_document(
chat_id=chat_id_int,
document=document,
caption=caption,
message_thread_id=thread_id,
parse_mode=parse_mode,
filename=filename
)

316
app/core/metrics.py Normal file
View File

@@ -0,0 +1,316 @@
"""
Централизованное управление метриками Prometheus.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Optional
from prometheus_client import Counter, CollectorRegistry, push_to_gateway
from functools import lru_cache
from app.core.config import settings
logger = logging.getLogger(__name__)
class MetricsManager:
"""Менеджер метрик Prometheus."""
def __init__(self):
"""Инициализация менеджера метрик."""
self.registry = CollectorRegistry()
self._init_metrics()
def _init_metrics(self) -> None:
"""Инициализация всех метрик."""
# API эндпоинты
self.api_endpoint_count = Counter(
'tg_monitoring_gateway_api_endpoint_total',
'Общее количество обращений к эндпоинтам API',
labelnames=['endpoint'],
registry=self.registry
)
# Сообщения по источникам
self.total_messages = Counter(
'tg_monitoring_gateway_total_messages',
'Всего сообщений получено',
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
registry=self.registry
)
self.sent_messages = Counter(
'tg_monitoring_gateway_sent_messages',
'Сообщений успешно отправлено',
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
registry=self.registry
)
self.reject_messages = Counter(
'tg_monitoring_gateway_reject_messages',
'Сообщений отклонено (стоп-слова)',
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
registry=self.registry
)
self.error_messages = Counter(
'tg_monitoring_gateway_error_messages',
'Ошибок отправки сообщений',
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
registry=self.registry
)
self.firing_messages = Counter(
'tg_monitoring_gateway_firing_messages',
'Горящих алертов',
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
registry=self.registry
)
self.critical_messages = Counter(
'tg_monitoring_gateway_critical_messages',
'Критических алертов',
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
registry=self.registry
)
self.resolved_messages = Counter(
'tg_monitoring_gateway_resolved_messages',
'Исправленных алертов',
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
registry=self.registry
)
# Jira метрики
self.jira_tickets_created = Counter(
'tg_monitoring_gateway_jira_tickets_created',
'Jira тикетов создано',
labelnames=['source', 'k8s_cluster', 'chat', 'thread', 'project'],
registry=self.registry
)
self.jira_tickets_errors = Counter(
'tg_monitoring_gateway_jira_tickets_errors',
'Ошибок создания Jira тикетов',
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
registry=self.registry
)
def increment_api_endpoint(self, endpoint: str) -> None:
"""
Увеличить счетчик обращений к эндпоинту API.
Args:
endpoint: Имя эндпоинта.
"""
self.api_endpoint_count.labels(endpoint=endpoint).inc()
self._push_metrics()
def increment_total_message(
self,
source: str,
k8s_cluster: Optional[str] = None,
chat: Optional[str] = None,
thread: Optional[int] = None
) -> None:
"""
Увеличить счетчик полученных сообщений.
Args:
source: Источник сообщения (grafana, zabbix, alertmanager).
k8s_cluster: Имя Kubernetes кластера (опционально).
chat: Имя чата (опционально).
thread: ID треда (опционально).
"""
k8s_cluster = k8s_cluster or ""
chat = chat or ""
thread = thread or 0
self.total_messages.labels(
source=source,
k8s_cluster=k8s_cluster,
chat=chat,
thread=thread
).inc()
self._push_metrics()
def increment_sent_message(
self,
source: str,
k8s_cluster: Optional[str] = None,
chat: Optional[str] = None,
thread: Optional[int] = None
) -> None:
"""Увеличить счетчик отправленных сообщений."""
k8s_cluster = k8s_cluster or ""
chat = chat or ""
thread = thread or 0
self.sent_messages.labels(
source=source,
k8s_cluster=k8s_cluster,
chat=chat,
thread=thread
).inc()
self._push_metrics()
def increment_reject_message(
self,
source: str,
k8s_cluster: Optional[str] = None,
chat: Optional[str] = None,
thread: Optional[int] = None
) -> None:
"""Увеличить счетчик отклоненных сообщений."""
k8s_cluster = k8s_cluster or ""
chat = chat or ""
thread = thread or 0
self.reject_messages.labels(
source=source,
k8s_cluster=k8s_cluster,
chat=chat,
thread=thread
).inc()
self._push_metrics()
def increment_error_message(
self,
source: str,
k8s_cluster: Optional[str] = None,
chat: Optional[str] = None,
thread: Optional[int] = None
) -> None:
"""Увеличить счетчик ошибок отправки."""
k8s_cluster = k8s_cluster or ""
chat = chat or ""
thread = thread or 0
self.error_messages.labels(
source=source,
k8s_cluster=k8s_cluster,
chat=chat,
thread=thread
).inc()
self._push_metrics()
def increment_firing_message(
self,
source: str,
k8s_cluster: Optional[str] = None,
chat: Optional[str] = None,
thread: Optional[int] = None
) -> None:
"""Увеличить счетчик горящих алертов."""
k8s_cluster = k8s_cluster or ""
chat = chat or ""
thread = thread or 0
self.firing_messages.labels(
source=source,
k8s_cluster=k8s_cluster,
chat=chat,
thread=thread
).inc()
self._push_metrics()
def increment_critical_message(
self,
source: str,
k8s_cluster: Optional[str] = None,
chat: Optional[str] = None,
thread: Optional[int] = None
) -> None:
"""Увеличить счетчик критических алертов."""
k8s_cluster = k8s_cluster or ""
chat = chat or ""
thread = thread or 0
self.critical_messages.labels(
source=source,
k8s_cluster=k8s_cluster,
chat=chat,
thread=thread
).inc()
self._push_metrics()
def increment_resolved_message(
self,
source: str,
k8s_cluster: Optional[str] = None,
chat: Optional[str] = None,
thread: Optional[int] = None
) -> None:
"""Увеличить счетчик исправленных алертов."""
k8s_cluster = k8s_cluster or ""
chat = chat or ""
thread = thread or 0
self.resolved_messages.labels(
source=source,
k8s_cluster=k8s_cluster,
chat=chat,
thread=thread
).inc()
self._push_metrics()
def increment_jira_ticket_created(
self,
source: str,
project: str,
k8s_cluster: Optional[str] = None,
chat: Optional[str] = None,
thread: Optional[int] = None
) -> None:
"""Увеличить счетчик созданных Jira тикетов."""
k8s_cluster = k8s_cluster or ""
chat = chat or ""
thread = thread or 0
self.jira_tickets_created.labels(
source=source,
k8s_cluster=k8s_cluster,
chat=chat,
thread=thread,
project=project
).inc()
self._push_metrics()
def increment_jira_ticket_error(
self,
source: str,
k8s_cluster: Optional[str] = None,
chat: Optional[str] = None,
thread: Optional[int] = None
) -> None:
"""Увеличить счетчик ошибок создания Jira тикетов."""
k8s_cluster = k8s_cluster or ""
chat = chat or ""
thread = thread or 0
self.jira_tickets_errors.labels(
source=source,
k8s_cluster=k8s_cluster,
chat=chat,
thread=thread
).inc()
self._push_metrics()
def _push_metrics(self) -> None:
"""Отправить метрики в Pushgateway."""
from app.core.config import get_settings
settings = get_settings()
if not settings.pushgateway_url:
return
try:
push_to_gateway(
settings.pushgateway_url,
job=settings.pushgateway_job,
registry=self.registry
)
except Exception as e:
logger.error(f"Ошибка отправки метрик в Pushgateway: {e}")
# Глобальный экземпляр менеджера метрик
@lru_cache(maxsize=1)
def get_metrics_manager() -> MetricsManager:
"""Получить глобальный экземпляр менеджера метрик."""
return MetricsManager()
metrics = get_metrics_manager()

345
app/core/telegram_client.py Normal file
View File

@@ -0,0 +1,345 @@
"""
Клиент для работы с Telegram Bot API.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
import io
from typing import Optional, Union
from telegram import Bot, InlineKeyboardMarkup
from telegram.error import TelegramError
import httpx
logger = logging.getLogger(__name__)
class TelegramClient:
"""Клиент для отправки сообщений в Telegram."""
def __init__(self, bot_token: Optional[str] = None):
"""
Инициализация клиента Telegram.
Args:
bot_token: Токен бота Telegram. Если не указан, используется из настроек.
Raises:
ValueError: Если токен не указан.
"""
# Импортируем settings здесь, чтобы избежать циклических зависимостей
from app.core.config import get_settings
settings = get_settings()
self.bot_token = bot_token or settings.telegram_bot_token
if not self.bot_token:
raise ValueError("TELEGRAM_BOT_TOKEN не установлен")
self._bot: Optional[Bot] = None
async def get_bot(self) -> Bot:
"""
Получить экземпляр бота (создается при первом обращении).
Returns:
Экземпляр Bot.
"""
if self._bot is None:
self._bot = Bot(token=self.bot_token)
return self._bot
async def send_message(
self,
chat_id: int,
text: str,
message_thread_id: Optional[int] = None,
reply_markup: Optional[InlineKeyboardMarkup] = None,
disable_web_page_preview: bool = True,
parse_mode: str = "HTML"
) -> bool:
"""
Отправить сообщение в Telegram.
Args:
chat_id: ID чата или группы.
text: Текст сообщения.
message_thread_id: ID треда в группе (опционально).
reply_markup: Клавиатура с кнопками (опционально).
disable_web_page_preview: Отключить превью ссылок.
parse_mode: Режим парсинга (HTML, Markdown).
Returns:
True если сообщение отправлено успешно, False в противном случае.
"""
try:
bot = await self.get_bot()
await bot.send_message(
chat_id=chat_id,
text=text,
message_thread_id=message_thread_id,
disable_web_page_preview=disable_web_page_preview,
parse_mode=parse_mode,
reply_markup=reply_markup
)
logger.info(f"Сообщение отправлено в чат {chat_id}, тред {message_thread_id}")
return True
except TelegramError as e:
logger.error(f"Ошибка отправки сообщения в Telegram: {e}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке сообщения: {e}")
return False
async def send_photo(
self,
chat_id: int,
photo: Union[str, bytes, io.BytesIO],
caption: Optional[str] = None,
message_thread_id: Optional[int] = None,
parse_mode: str = "HTML"
) -> bool:
"""
Отправить фото в Telegram.
Args:
chat_id: ID чата или группы.
photo: Путь к файлу, URL, bytes или BytesIO объект с фото.
caption: Подпись к фото (опционально).
message_thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
Returns:
True если фото отправлено успешно, False в противном случае.
"""
try:
bot = await self.get_bot()
# Если photo - это URL, скачиваем файл
if isinstance(photo, str) and (photo.startswith('http://') or photo.startswith('https://')):
async with httpx.AsyncClient() as client:
response = await client.get(photo)
photo = io.BytesIO(response.content)
# Если photo - это bytes, преобразуем в BytesIO
if isinstance(photo, bytes):
photo = io.BytesIO(photo)
await bot.send_photo(
chat_id=chat_id,
photo=photo,
caption=caption,
message_thread_id=message_thread_id,
parse_mode=parse_mode
)
logger.info(f"Фото отправлено в чат {chat_id}, тред {message_thread_id}")
return True
except TelegramError as e:
logger.error(f"Ошибка отправки фото в Telegram: {e}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке фото: {e}")
return False
async def send_video(
self,
chat_id: int,
video: Union[str, bytes, io.BytesIO],
caption: Optional[str] = None,
message_thread_id: Optional[int] = None,
parse_mode: str = "HTML",
duration: Optional[int] = None,
width: Optional[int] = None,
height: Optional[int] = None
) -> bool:
"""
Отправить видео в Telegram.
Args:
chat_id: ID чата или группы.
video: Путь к файлу, URL, bytes или BytesIO объект с видео.
caption: Подпись к видео (опционально).
message_thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
duration: Длительность видео в секундах (опционально).
width: Ширина видео (опционально).
height: Высота видео (опционально).
Returns:
True если видео отправлено успешно, False в противном случае.
"""
try:
bot = await self.get_bot()
# Если video - это URL, скачиваем файл
if isinstance(video, str) and (video.startswith('http://') or video.startswith('https://')):
async with httpx.AsyncClient() as client:
response = await client.get(video)
video = io.BytesIO(response.content)
# Если video - это bytes, преобразуем в BytesIO
if isinstance(video, bytes):
video = io.BytesIO(video)
await bot.send_video(
chat_id=chat_id,
video=video,
caption=caption,
message_thread_id=message_thread_id,
parse_mode=parse_mode,
duration=duration,
width=width,
height=height
)
logger.info(f"Видео отправлено в чат {chat_id}, тред {message_thread_id}")
return True
except TelegramError as e:
logger.error(f"Ошибка отправки видео в Telegram: {e}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке видео: {e}")
return False
async def send_audio(
self,
chat_id: int,
audio: Union[str, bytes, io.BytesIO],
caption: Optional[str] = None,
message_thread_id: Optional[int] = None,
parse_mode: str = "HTML",
duration: Optional[int] = None,
performer: Optional[str] = None,
title: Optional[str] = None
) -> bool:
"""
Отправить аудио в Telegram.
Args:
chat_id: ID чата или группы.
audio: Путь к файлу, URL, bytes или BytesIO объект с аудио.
caption: Подпись к аудио (опционально).
message_thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
duration: Длительность аудио в секундах (опционально).
performer: Исполнитель (опционально).
title: Название трека (опционально).
Returns:
True если аудио отправлено успешно, False в противном случае.
"""
try:
bot = await self.get_bot()
# Если audio - это URL, скачиваем файл
if isinstance(audio, str) and (audio.startswith('http://') or audio.startswith('https://')):
async with httpx.AsyncClient() as client:
response = await client.get(audio)
audio = io.BytesIO(response.content)
# Если audio - это bytes, преобразуем в BytesIO
if isinstance(audio, bytes):
audio = io.BytesIO(audio)
await bot.send_audio(
chat_id=chat_id,
audio=audio,
caption=caption,
message_thread_id=message_thread_id,
parse_mode=parse_mode,
duration=duration,
performer=performer,
title=title
)
logger.info(f"Аудио отправлено в чат {chat_id}, тред {message_thread_id}")
return True
except TelegramError as e:
logger.error(f"Ошибка отправки аудио в Telegram: {e}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке аудио: {e}")
return False
async def send_document(
self,
chat_id: int,
document: Union[str, bytes, io.BytesIO],
caption: Optional[str] = None,
message_thread_id: Optional[int] = None,
parse_mode: str = "HTML",
filename: Optional[str] = None
) -> bool:
"""
Отправить документ в Telegram.
Args:
chat_id: ID чата или группы.
document: Путь к файлу, URL, bytes или BytesIO объект с документом.
caption: Подпись к документу (опционально).
message_thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
filename: Имя файла (опционально).
Returns:
True если документ отправлен успешно, False в противном случае.
"""
try:
bot = await self.get_bot()
document_url = None
# Если document - это URL, скачиваем файл
if isinstance(document, str) and (document.startswith('http://') or document.startswith('https://')):
document_url = document
async with httpx.AsyncClient() as client:
response = await client.get(document)
document = io.BytesIO(response.content)
if not filename:
# Пытаемся извлечь имя файла из URL
filename = document_url.split('/')[-1].split('?')[0]
# Если document - это bytes, преобразуем в BytesIO
if isinstance(document, bytes):
document = io.BytesIO(document)
await bot.send_document(
chat_id=chat_id,
document=document,
caption=caption,
message_thread_id=message_thread_id,
parse_mode=parse_mode,
filename=filename
)
logger.info(f"Документ отправлен в чат {chat_id}, тред {message_thread_id}")
return True
except TelegramError as e:
logger.error(f"Ошибка отправки документа в Telegram: {e}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке документа: {e}")
return False
# Глобальный экземпляр клиента (lazy initialization)
_telegram_client_instance: Optional[TelegramClient] = None
def get_telegram_client() -> TelegramClient:
"""
Получить экземпляр клиента Telegram (lazy initialization).
Returns:
Экземпляр TelegramClient.
"""
global _telegram_client_instance
if _telegram_client_instance is None:
_telegram_client_instance = TelegramClient()
return _telegram_client_instance
# Для обратной совместимости (lazy initialization)
# telegram_client будет создан при первом использовании
class _TelegramClientProxy:
"""Прокси для ленивой инициализации telegram_client."""
def __getattr__(self, name):
"""Получить атрибут из telegram_client."""
return getattr(get_telegram_client(), name)
telegram_client = _TelegramClientProxy()

97
app/core/utils.py Normal file
View File

@@ -0,0 +1,97 @@
"""
Вспомогательные утилиты.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import re
import logging
from typing import List
logger = logging.getLogger(__name__)
# Список стоп-слов (алерты, которые не должны отправляться)
STOP_WORDS = [
r"^InfoInhibitor",
r"^Watchdog",
r"^[E,e]tcdHighCommitDurations",
r"^[E,e]tcdHighNumberOfFailedGRPCRequests",
r"^[K,k]ubePersistentVolumeFillingUp",
r"^[K,k]ubePersistentVolumeInodesFillingUp",
]
def check_stop_words(name: str) -> bool:
"""
Проверить, содержит ли название алерта стоп-слова.
Args:
name: Название алерта.
Returns:
True если алерт должен быть заблокирован, False в противном случае.
"""
logger.debug(f"Проверка стоп-слов для алерта: '{name}'")
for pattern in STOP_WORDS:
if re.search(pattern, name):
logger.warning(f"Алерт '{name}' заблокирован стоп-словом: {pattern}")
return True
return False
def add_spaces_to_alert_name(name: str) -> str:
"""
Добавить пробелы в название алерта для лучшей читаемости.
Пример: "HighCPUUsage" -> "High CPU Usage"
Args:
name: Название алерта без пробелов.
Returns:
Название алерта с пробелами.
"""
if not name:
return name
result = name[0]
for letter in name[1:]:
if letter.isupper():
result += f' {letter}'
else:
result += letter
# Исправляем известные сокращения
result = result.replace('C P U', 'CPU')
result = result.replace('etcd', 'ETCD')
result = result.replace('A P I', 'API')
result = result.replace('K 8 S', 'K8S')
result = result.replace('P V C', 'PVC')
result = result.replace('G R P C', 'GRPC')
return result
def truncate_message(message: str, max_length: int = 4090) -> str:
"""
Обрезать сообщение до максимальной длины для Telegram.
Telegram имеет лимит в 4096 символов на сообщение.
Args:
message: Исходное сообщение.
max_length: Максимальная длина сообщения.
Returns:
Обрезанное сообщение с индикацией обрезки.
"""
if len(message) <= max_length:
return message
truncated = message[:max_length - 10]
truncated += "\n\n... (сообщение обрезано)"
logger.warning(f"Сообщение обрезано с {len(message)} до {max_length} символов")
return truncated

187
app/main.py Normal file
View File

@@ -0,0 +1,187 @@
"""
Главный модуль приложения Telegram Gateway.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from fastapi import FastAPI
from app.common.cors import add as add_cors
from app.common.telemetry import add as add_telemetry
from app.api.v1.router import api_router
# Настройка логирования (базовая настройка, детальная настройка в app.common.logger)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Создаем приложение FastAPI
app = FastAPI(
title="Message Gateway",
summary="Отправляем мониторинговые алерты в телеграм/MAX и создаем тикеты в Jira",
description=(
"Приложение для оповещений, приходящих из Grafana/Zabbix/AlertManager "
"посредством вебхука, в телеграм/MAX. С возможностью отправок в треды и создания тикетов в Jira. "
"<br><br><b>Что бы начать отправлять сообщения</b>, добавьте бота "
"<b>@CismGlobalMonitoring_bot</b> в чат и <b>внесите изменения в группы</b>"
),
version="0.2.0",
contact={
"name": "Сергей Антропов",
"url": "https://devops.org.ru/contact/",
"email": "sergey@antropoff.ru",
},
license_info={
"name": "Apache 2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
},
debug=False,
swagger_ui_init_oauth={
"clientId": "api-key",
"appName": "Message Gateway API",
"usePkceWithAuthorizationCodeGrant": False,
}
)
# Добавляем схему безопасности в Swagger
from fastapi.openapi.utils import get_openapi
from fastapi.routing import APIRoute
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
# Собираем пути, которые должны быть скрыты от API
hidden_paths = set()
# Перебираем все routes и находим те, которые помечены как скрытые
for route in app.routes:
# Проверяем только APIRoute
if not isinstance(route, APIRoute):
continue
# Получаем endpoint функцию
endpoint = route.endpoint
# Проверяем, помечен ли endpoint как скрытый от API
if hasattr(endpoint, "__hide_from_api__") and endpoint.__hide_from_api__:
# Получаем полный путь (с учетом префиксов)
path = route.path
# Нормализуем путь (убираем параметры типа {param} для сопоставления)
# Но нам нужно точное сопоставление, поэтому используем полный путь
hidden_paths.add(path)
logger.debug(f"Эндпоинт {path} помечен как скрытый от API")
# Генерируем схему как обычно
openapi_schema = get_openapi(
title=app.title,
version=app.version,
description=app.description,
routes=app.routes,
)
# Удаляем скрытые пути из схемы
if hidden_paths:
paths = openapi_schema.get("paths", {})
# Создаем новый словарь paths без скрытых путей
filtered_paths = {}
for path, path_item in paths.items():
# Проверяем, должен ли путь быть скрыт
# Путь в схеме должен точно совпадать с путем в route.path
# route.path уже содержит все префиксы (router prefix + route path)
should_hide = path in hidden_paths
if not should_hide:
filtered_paths[path] = path_item
else:
logger.debug(f"Удаляем путь {path} из OpenAPI схемы")
openapi_schema["paths"] = filtered_paths
# Добавляем схему безопасности API Key
if "components" not in openapi_schema:
openapi_schema["components"] = {}
if "securitySchemes" not in openapi_schema["components"]:
openapi_schema["components"]["securitySchemes"] = {}
# Определяем имя схемы безопасности, которое использует FastAPI
# FastAPI автоматически генерирует имя на основе класса Security
# Обычно это имя класса в camelCase (например, "APIKeyHeader")
api_key_scheme_name = None
# Перебираем существующие схемы безопасности и находим API Key схему
for scheme_name, scheme_def in openapi_schema["components"].get("securitySchemes", {}).items():
if scheme_def.get("type") == "apiKey" and scheme_def.get("name") == "X-API-Key":
api_key_scheme_name = scheme_name
break
# Если схема не найдена, создаем новую с именем "ApiKeyAuth"
if not api_key_scheme_name:
api_key_scheme_name = "ApiKeyAuth"
openapi_schema["components"]["securitySchemes"][api_key_scheme_name] = {
"type": "apiKey",
"in": "header",
"name": "X-API-Key",
"description": "API ключ для авторизации. Получите его из переменной окружения API_KEY."
}
else:
# Обновляем существующую схему, если она уже есть
openapi_schema["components"]["securitySchemes"][api_key_scheme_name] = {
"type": "apiKey",
"in": "header",
"name": "X-API-Key",
"description": "API ключ для авторизации. Получите его из переменной окружения API_KEY."
}
# Если имя схемы не "ApiKeyAuth", переименовываем ее
if api_key_scheme_name != "ApiKeyAuth":
# Сохраняем старую схему
old_scheme = openapi_schema["components"]["securitySchemes"].pop(api_key_scheme_name)
# Создаем новую схему с именем "ApiKeyAuth"
openapi_schema["components"]["securitySchemes"]["ApiKeyAuth"] = old_scheme
# Заменяем все использования старого имени на новое в security эндпоинтов
for path, path_item in openapi_schema.get("paths", {}).items():
for method, operation in path_item.items():
if isinstance(operation, dict) and "security" in operation:
security_list = operation["security"]
for security_item in security_list:
if api_key_scheme_name in security_item:
# Заменяем старое имя на новое
security_item["ApiKeyAuth"] = security_item.pop(api_key_scheme_name)
# Убеждаемся, что схема "ApiKeyAuth" существует
if "ApiKeyAuth" not in openapi_schema["components"]["securitySchemes"]:
openapi_schema["components"]["securitySchemes"]["ApiKeyAuth"] = {
"type": "apiKey",
"in": "header",
"name": "X-API-Key",
"description": "API ключ для авторизации. Получите его из переменной окружения API_KEY."
}
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi
# Добавляем CORS
add_cors(app)
# Добавляем телеметрию
add_telemetry(app)
# Подключаем роутер API v1
app.include_router(api_router)
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8000,
reload=False
)

16
app/models/__init__.py Normal file
View File

@@ -0,0 +1,16 @@
"""
Модели данных для приложения.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from app.models.alertmanager import PrometheusAlert
from app.models.grafana import GrafanaAlert, EvalMatch
from app.models.zabbix import ZabbixAlert
__all__ = [
"PrometheusAlert",
"GrafanaAlert",
"EvalMatch",
"ZabbixAlert",
]

View File

@@ -0,0 +1,69 @@
"""
Модели данных для AlertManager webhooks.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from typing import Dict, Any, Optional
from pydantic import BaseModel, Field
class PrometheusAlert(BaseModel):
"""Модель данных вебхука из AlertManager."""
status: str = Field(..., description="Статус алерта (firing, resolved)", examples=["firing"])
externalURL: str = Field(..., description="Внешний URL AlertManager", examples=["http://alertmanager.example.com"])
commonLabels: Dict[str, str] = Field(..., description="Общие метки алерта")
commonAnnotations: Dict[str, str] = Field(..., description="Общие аннотации алерта")
model_config = {
"json_schema_extra": {
"examples": [
{
"status": "firing",
"externalURL": "http://alertmanager.example.com",
"commonLabels": {
"alertname": "HighCPUUsage",
"severity": "critical",
"namespace": "production",
"pod": "app-deployment-7d8f9b4c5-abc123",
"container": "app-container"
},
"commonAnnotations": {
"summary": "High CPU usage detected in production namespace",
"description": "CPU usage is above 90% for 5 minutes on pod app-deployment-7d8f9b4c5-abc123",
"runbook_url": "https://wiki.example.com/runbooks/high-cpu-usage"
}
},
{
"status": "resolved",
"externalURL": "http://alertmanager.example.com",
"commonLabels": {
"alertname": "HighCPUUsage",
"severity": "critical",
"namespace": "production",
"pod": "app-deployment-7d8f9b4c5-abc123",
"container": "app-container"
},
"commonAnnotations": {
"summary": "High CPU usage resolved in production namespace",
"description": "CPU usage has returned to normal levels on pod app-deployment-7d8f9b4c5-abc123"
}
},
{
"status": "firing",
"externalURL": "http://alertmanager.example.com",
"commonLabels": {
"alertname": "PodCrashLooping",
"severity": "warning",
"namespace": "staging",
"pod": "test-app-5f6g7h8i9-jkl456"
},
"commonAnnotations": {
"summary": "Pod is crash looping",
"description": "Pod test-app-5f6g7h8i9-jkl456 has restarted 5 times in the last 10 minutes"
}
}
]
}
}

80
app/models/grafana.py Normal file
View File

@@ -0,0 +1,80 @@
"""
Модели данных для Grafana webhooks.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
class EvalMatch(BaseModel):
"""Модель для evalMatches из Grafana."""
value: float = Field(..., description="Значение метрики", examples=[95.5, 87.2, 45.2])
metric: str = Field(..., description="Название метрики", examples=["cpu_usage_percent", "memory_usage_percent", "disk_usage_percent"])
tags: Optional[Dict[str, Any]] = Field(None, description="Теги метрики", examples=[{"host": "server01", "instance": "production"}, None])
class GrafanaAlert(BaseModel):
"""Модель данных вебхука из Grafana."""
title: str = Field(..., description="Заголовок алерта", examples=["[Alerting] Test notification"])
ruleId: int = Field(..., description="ID правила алерта", examples=[674180201771804383])
ruleName: str = Field(..., description="Название правила", examples=["Test notification"])
state: str = Field(..., description="Состояние алерта (alerting, ok, paused, pending, no_data)", examples=["alerting"])
evalMatches: List[EvalMatch] = Field(default_factory=list, description="Совпадения метрик")
orgId: int = Field(..., description="ID организации", examples=[0])
dashboardId: int = Field(..., description="ID дашборда", examples=[1])
panelId: int = Field(..., description="ID панели", examples=[1])
tags: Dict[str, str] = Field(default_factory=dict, description="Теги алерта")
ruleUrl: str = Field(..., description="URL правила алерта", examples=["http://grafana.cism-ms.ru/"])
message: Optional[str] = Field(None, description="Сообщение алерта", examples=["Someone is testing the alert notification within Grafana."])
model_config = {
"json_schema_extra": {
"examples": [
{
"title": "[Alerting] High CPU Usage",
"ruleId": 674180201771804383,
"ruleName": "High CPU Usage Alert",
"state": "alerting",
"evalMatches": [
{
"value": 95.5,
"metric": "cpu_usage_percent",
"tags": {"host": "server01", "instance": "production"}
},
{
"value": 87.2,
"metric": "memory_usage_percent",
"tags": {"host": "server01", "instance": "production"}
}
],
"orgId": 1,
"dashboardId": 123,
"panelId": 456,
"tags": {"severity": "critical", "environment": "production"},
"ruleUrl": "http://grafana.cism-ms.ru/alerting/list",
"message": "CPU usage is above 90% threshold for more than 5 minutes"
},
{
"title": "[OK] High CPU Usage",
"ruleId": 674180201771804383,
"ruleName": "High CPU Usage Alert",
"state": "ok",
"evalMatches": [
{
"value": 45.2,
"metric": "cpu_usage_percent",
"tags": {"host": "server01", "instance": "production"}
}
],
"orgId": 1,
"dashboardId": 123,
"panelId": 456,
"tags": {"severity": "critical", "environment": "production"},
"ruleUrl": "http://grafana.cism-ms.ru/alerting/list",
"message": "CPU usage has returned to normal levels"
}
]
}
}

106
app/models/group.py Normal file
View File

@@ -0,0 +1,106 @@
"""
Модели данных для управления группами мессенджеров.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from typing import Optional, Dict, Any, Union
from pydantic import BaseModel, Field
class GroupInfo(BaseModel):
"""Информация о группе."""
name: str = Field(..., description="Имя группы", examples=["monitoring", "alerts", "devops"])
messenger: Optional[str] = Field(None, description="Тип мессенджера (telegram, max)", examples=["telegram", "max"])
chat_id: Optional[Union[int, str]] = Field(None, description="Chat ID группы (отображается только при наличии пароля)", examples=[-1001234567890, "123456789", None])
thread_id: Optional[int] = Field(None, description="ID треда в группе (опционально, только для Telegram)", examples=[0, 123, 456])
model_config = {
"json_schema_extra": {
"examples": [
{
"name": "monitoring",
"messenger": "telegram",
"chat_id": -1001234567890,
"thread_id": 0
},
{
"name": "alerts_max",
"messenger": "max",
"chat_id": "123456789",
"thread_id": None
},
{
"name": "devops",
"messenger": None,
"chat_id": None,
"thread_id": None
}
]
}
}
class CreateGroupRequest(BaseModel):
"""Запрос на создание группы."""
group_name: str = Field(..., description="Имя группы", examples=["monitoring", "alerts", "devops"])
chat_id: Union[int, str] = Field(..., description="ID чата (может быть int для Telegram или str для MAX/VK)", examples=[-1001234567890, "123456789"])
messenger: str = Field("telegram", description="Тип мессенджера (telegram, max)", examples=["telegram", "max"])
thread_id: int = Field(0, description="ID треда в группе (по умолчанию 0, только для Telegram)", examples=[0, 123, 456])
config: Optional[Dict[str, Any]] = Field(None, description="Дополнительная конфигурация для мессенджера (опционально)", examples=[None, {"access_token": "..."}, {"api_version": "5.131"}])
model_config = {
"json_schema_extra": {
"examples": [
{
"group_name": "monitoring",
"chat_id": -1001234567890,
"messenger": "telegram",
"thread_id": 0,
},
{
"group_name": "alerts_max",
"chat_id": "123456789",
"messenger": "max",
"thread_id": 0,
"config": {
"access_token": "your_access_token",
"api_version": "5.131"
}
}
]
}
}
class UpdateGroupRequest(BaseModel):
"""Запрос на обновление группы."""
chat_id: Optional[Union[int, str]] = Field(None, description="Новый Chat ID группы (можно получить через @userinfobot для Telegram)", examples=[-1001234567891, "123456789"])
messenger: Optional[str] = Field(None, description="Новый тип мессенджера (telegram, max)", examples=["telegram", "max"])
thread_id: Optional[int] = Field(None, description="Новый ID треда в группе (опционально, только для Telegram)", examples=[0, 123, 456])
config: Optional[Dict[str, Any]] = Field(None, description="Новая дополнительная конфигурация для мессенджера (опционально)", examples=[None, {"access_token": "..."}, {"api_version": "5.131"}])
model_config = {
"json_schema_extra": {
"examples": [
{
"chat_id": -1001234567891,
"messenger": "telegram",
"thread_id": 0,
},
{
"chat_id": "123456789",
"messenger": "max",
"config": {
"access_token": "your_access_token",
"api_version": "5.131"
}
}
]
}
}
class DeleteGroupRequest(BaseModel):
"""Запрос на удаление группы."""
pass

73
app/models/jira.py Normal file
View File

@@ -0,0 +1,73 @@
"""
Модели данных для Jira тикетов.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from typing import Optional, Dict, Any, List
from pydantic import BaseModel, Field
class JiraMappingCondition(BaseModel):
"""Условия для маппинга алерта в Jira тикет."""
severity: Optional[str] = None
namespace: Optional[str] = None
state: Optional[str] = None
status: Optional[str] = None
tags: Optional[Dict[str, str]] = None
event_severity: Optional[str] = Field(None, alias="event-severity")
class JiraMapping(BaseModel):
"""Маппинг алерта в Jira тикет."""
conditions: JiraMappingCondition
project: str = Field(..., description="Ключ проекта Jira")
assignee: Optional[str] = Field(None, description="Email исполнителя")
issue_type: str = Field("Bug", description="Тип задачи")
priority: str = Field("High", description="Приоритет задачи")
labels: Optional[List[str]] = Field(None, description="Метки задачи")
model_config = {"populate_by_name": True}
class JiraSourceMapping(BaseModel):
"""Маппинг для источника алертов."""
default_project: str = Field(..., description="Проект по умолчанию")
default_assignee: Optional[str] = Field(None, description="Исполнитель по умолчанию")
default_issue_type: str = Field("Bug", description="Тип задачи по умолчанию")
default_priority: str = Field("High", description="Приоритет по умолчанию")
mappings: List[JiraMapping] = Field(default_factory=list, description="Список маппингов")
class JiraMappingConfig(BaseModel):
"""Конфигурация маппинга алертов в Jira тикеты."""
alertmanager: Optional[JiraSourceMapping] = None
grafana: Optional[JiraSourceMapping] = None
zabbix: Optional[JiraSourceMapping] = None
class JiraIssue(BaseModel):
"""Модель Jira тикета."""
project: str = Field(..., description="Ключ проекта")
summary: str = Field(..., description="Заголовок тикета")
description: str = Field(..., description="Описание тикета")
issue_type: str = Field("Bug", description="Тип задачи")
assignee: Optional[str] = Field(None, description="Email исполнителя")
priority: Optional[str] = Field(None, description="Приоритет задачи")
labels: Optional[List[str]] = Field(None, description="Метки задачи")
components: Optional[List[str]] = Field(None, description="Компоненты проекта")
model_config = {
"json_schema_extra": {
"examples": [{
"project": "MON",
"summary": "[Critical] High CPU Usage - Production",
"description": "**Alert:** High CPU Usage\n\n**Severity:** Critical\n\n**Namespace:** production",
"issue_type": "Bug",
"assignee": "devops-team@example.com",
"priority": "Highest",
"labels": ["critical", "production", "alertmanager"]
}]
}
}

195
app/models/message.py Normal file
View File

@@ -0,0 +1,195 @@
"""
Модели данных для отправки простых сообщений в Telegram.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from typing import Optional
from pydantic import BaseModel, Field
class SendMessageRequest(BaseModel):
"""Запрос на отправку текстового сообщения."""
tg_group: str = Field(..., description="Имя группы Telegram из конфигурации", examples=["monitoring", "alerts", "devops"])
tg_thread_id: int = Field(0, description="ID треда в группе Telegram (0 для основной группы)", examples=[0, 123, 456])
text: str = Field(..., description="Текст сообщения", examples=["Привет! Это тестовое сообщение.", "<b>Важное уведомление</b>\n\nСистема работает нормально."])
parse_mode: Optional[str] = Field("HTML", description="Режим парсинга (HTML, Markdown, MarkdownV2)", examples=["HTML", "Markdown", "MarkdownV2"])
disable_web_page_preview: bool = Field(True, description="Отключить превью ссылок", examples=[True, False])
model_config = {
"json_schema_extra": {
"examples": [
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"text": "Привет! Это тестовое сообщение.",
"parse_mode": "HTML",
"disable_web_page_preview": True
},
{
"tg_group": "alerts",
"tg_thread_id": 123,
"text": "<b>Критическое уведомление</b>\n\nСистема недоступна!\n\n<i>Время: 2024-02-08 16:49:44</i>",
"parse_mode": "HTML",
"disable_web_page_preview": False
},
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"text": "**Важное уведомление**\n\nСистема работает нормально.\n\n[Подробнее](https://example.com)",
"parse_mode": "Markdown",
"disable_web_page_preview": True
}
]
}
}
class SendPhotoRequest(BaseModel):
"""Запрос на отправку фото."""
tg_group: str = Field(..., description="Имя группы Telegram из конфигурации", examples=["monitoring", "alerts"])
tg_thread_id: int = Field(0, description="ID треда в группе Telegram (0 для основной группы)", examples=[0, 123])
photo: str = Field(..., description="URL фото или путь к файлу (поддерживается автоматическая загрузка с URL)", examples=["https://example.com/image.jpg", "https://grafana.example.com/render/dashboard-solo?panelId=1&width=1000&height=500"])
caption: Optional[str] = Field(None, description="Подпись к фото", examples=["График производительности", "<b>График CPU</b>\n\nВремя: 2024-02-08 16:49:44"])
parse_mode: Optional[str] = Field("HTML", description="Режим парсинга подписи (HTML, Markdown, MarkdownV2)", examples=["HTML", "Markdown"])
model_config = {
"json_schema_extra": {
"examples": [
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"photo": "https://example.com/image.jpg",
"caption": "Описание фото",
"parse_mode": "HTML"
},
{
"tg_group": "alerts",
"tg_thread_id": 123,
"photo": "https://grafana.example.com/render/dashboard-solo?panelId=1&width=1000&height=500",
"caption": "<b>График CPU</b>\n\n<i>Время: 2024-02-08 16:49:44</i>",
"parse_mode": "HTML"
}
]
}
}
class SendVideoRequest(BaseModel):
"""Запрос на отправку видео."""
tg_group: str = Field(..., description="Имя группы Telegram из конфигурации", examples=["monitoring", "alerts"])
tg_thread_id: int = Field(0, description="ID треда в группе Telegram (0 для основной группы)", examples=[0, 123])
video: str = Field(..., description="URL видео или путь к файлу (поддерживается автоматическая загрузка с URL)", examples=["https://example.com/video.mp4", "https://example.com/recording.webm"])
caption: Optional[str] = Field(None, description="Подпись к видео", examples=["Запись экрана", "<b>Запись работы системы</b>\n\nДлительность: 60 сек"])
parse_mode: Optional[str] = Field("HTML", description="Режим парсинга подписи (HTML, Markdown, MarkdownV2)", examples=["HTML", "Markdown"])
duration: Optional[int] = Field(None, description="Длительность видео в секундах", examples=[60, 120, 300])
width: Optional[int] = Field(None, description="Ширина видео в пикселях", examples=[1280, 1920])
height: Optional[int] = Field(None, description="Высота видео в пикселях", examples=[720, 1080])
model_config = {
"json_schema_extra": {
"examples": [
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"video": "https://example.com/video.mp4",
"caption": "Описание видео",
"parse_mode": "HTML",
"duration": 60,
"width": 1280,
"height": 720
},
{
"tg_group": "alerts",
"tg_thread_id": 123,
"video": "https://example.com/recording.webm",
"caption": "<b>Запись работы системы</b>\n\n<i>Длительность: 60 сек</i>",
"parse_mode": "HTML",
"duration": 60,
"width": 1920,
"height": 1080
}
]
}
}
class SendAudioRequest(BaseModel):
"""Запрос на отправку аудио."""
tg_group: str = Field(..., description="Имя группы Telegram из конфигурации", examples=["monitoring", "alerts"])
tg_thread_id: int = Field(0, description="ID треда в группе Telegram (0 для основной группы)", examples=[0, 123])
audio: str = Field(..., description="URL аудио файла или путь к файлу (поддерживается автоматическая загрузка с URL)", examples=["https://example.com/audio.mp3", "https://example.com/notification.ogg"])
caption: Optional[str] = Field(None, description="Подпись к аудио", examples=["Аудио уведомление", "<b>Аудио запись</b>\n\nДлительность: 3 мин"])
parse_mode: Optional[str] = Field("HTML", description="Режим парсинга подписи (HTML, Markdown, MarkdownV2)", examples=["HTML", "Markdown"])
duration: Optional[int] = Field(None, description="Длительность аудио в секундах", examples=[180, 300])
performer: Optional[str] = Field(None, description="Исполнитель (для музыкальных файлов)", examples=["Artist Name", "System Notification"])
title: Optional[str] = Field(None, description="Название трека (для музыкальных файлов)", examples=["Song Title", "Alert Notification"])
model_config = {
"json_schema_extra": {
"examples": [
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"audio": "https://example.com/audio.mp3",
"caption": "Описание аудио",
"parse_mode": "HTML",
"duration": 180,
"performer": "Artist Name",
"title": "Song Title"
},
{
"tg_group": "alerts",
"tg_thread_id": 123,
"audio": "https://example.com/notification.ogg",
"caption": "<b>Аудио уведомление</b>\n\n<i>Система работает нормально</i>",
"parse_mode": "HTML",
"duration": 30,
"performer": "System Notification",
"title": "Alert Notification"
}
]
}
}
class SendDocumentRequest(BaseModel):
"""Запрос на отправку документа."""
tg_group: str = Field(..., description="Имя группы Telegram из конфигурации", examples=["monitoring", "alerts"])
tg_thread_id: int = Field(0, description="ID треда в группе Telegram (0 для основной группы)", examples=[0, 123])
document: str = Field(..., description="URL документа или путь к файлу (поддерживается автоматическая загрузка с URL)", examples=["https://example.com/file.pdf", "https://example.com/report.xlsx"])
caption: Optional[str] = Field(None, description="Подпись к документу", examples=["Отчет за неделю", "<b>Отчет</b>\n\nДата: 2024-02-08"])
parse_mode: Optional[str] = Field("HTML", description="Режим парсинга подписи (HTML, Markdown, MarkdownV2)", examples=["HTML", "Markdown"])
filename: Optional[str] = Field(None, description="Имя файла (если не указано, используется имя из URL)", examples=["document.pdf", "report_2024-02-08.xlsx"])
model_config = {
"json_schema_extra": {
"examples": [
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"document": "https://example.com/file.pdf",
"caption": "Описание документа",
"parse_mode": "HTML",
"filename": "document.pdf"
},
{
"tg_group": "alerts",
"tg_thread_id": 123,
"document": "https://example.com/report.xlsx",
"caption": "<b>Отчет за неделю</b>\n\n<i>Дата: 2024-02-08</i>",
"parse_mode": "HTML",
"filename": "report_2024-02-08.xlsx"
},
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"document": "https://example.com/logs.txt",
"caption": "Логи системы",
"parse_mode": "HTML",
"filename": "system_logs_2024-02-08.txt"
}
]
}
}

81
app/models/zabbix.py Normal file
View File

@@ -0,0 +1,81 @@
"""
Модели данных для Zabbix webhooks.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from typing import Optional
from pydantic import BaseModel, Field
class ZabbixAlert(BaseModel):
"""Модель данных вебхука из Zabbix."""
link: str = Field(..., description="Ссылка на событие в Zabbix", examples=["{$ZABBIX_URL}/tr_events.php?triggerid=42667&eventid=8819711"])
status: str = Field(..., description="Статус события (OK, PROBLEM)", examples=["OK"])
action_id: str = Field(..., alias="action-id", description="ID действия", examples=["7"])
alert_subject: str = Field(..., alias="alert-subject", description="Тема алерта", examples=["Resolved in 1m 0s: High CPU utilization (over 90% for 5m)"])
alert_message: str = Field(..., alias="alert-message", description="Сообщение алерта", examples=["Problem has been resolved at 16:49:44 on 2024.02.08"])
event_id: str = Field(..., alias="event-id", description="ID события", examples=["8819711"])
event_name: str = Field(..., alias="event-name", description="Название события", examples=["High CPU utilization (over 90% for 5m)"])
event_nseverity: str = Field(..., alias="event-nseverity", description="Числовой уровень серьезности", examples=["2"])
event_opdata: Optional[str] = Field(None, alias="event-opdata", description="Операционные данные события", examples=["Current utilization: 70.9 %"])
event_recovery_date: Optional[str] = Field(None, alias="event-recovery-date", description="Дата восстановления", examples=["2024.02.08"])
event_recovery_time: Optional[str] = Field(None, alias="event-recovery-time", description="Время восстановления", examples=["16:49:44"])
event_duration: Optional[str] = Field(None, alias="event-duration", description="Длительность события", examples=["1m 0s"])
event_recovery_name: Optional[str] = Field(None, alias="event-recovery-name", description="Название восстановленного события", examples=["High CPU utilization (over 90% for 5m)"])
event_recovery_status: Optional[str] = Field(None, alias="event-recovery-status", description="Статус восстановления", examples=["RESOLVED"])
event_recovery_tags: Optional[str] = Field(None, alias="event-recovery-tags", description="Теги восстановленного события", examples=["Application:CPU"])
event_severity: Optional[str] = Field(None, alias="event-severity", description="Уровень серьезности (Disaster, High, Warning, Average, Information)", examples=["Warning"])
host_name: str = Field(..., alias="host-name", description="Имя хоста", examples=["pnode28"])
host_ip: str = Field(..., alias="host-ip", description="IP адрес хоста", examples=["10.14.253.38"])
host_port: str = Field(..., alias="host-port", description="Порт хоста", examples=["10050"])
model_config = {
"populate_by_name": True,
"json_schema_extra": {
"examples": [
{
"link": "https://zabbix.example.com/tr_events.php?triggerid=42667&eventid=8819711",
"status": "PROBLEM",
"action-id": "7",
"alert-subject": "Problem: High CPU utilization (over 90% for 5m)",
"alert-message": "Problem started at 16:48:44 on 2024.02.08\r\nProblem name: High CPU utilization (over 90% for 5m)\r\nHost: pnode28\r\nSeverity: Warning\r\nCurrent utilization: 95.2 %\r\n",
"event-id": "8819711",
"event-name": "High CPU utilization (over 90% for 5m)",
"event-nseverity": "2",
"event-opdata": "Current utilization: 95.2 %",
"event-recovery-date": None,
"event-recovery-time": None,
"event-duration": None,
"event-recovery-name": None,
"event-recovery-status": None,
"event-recovery-tags": None,
"event-severity": "Warning",
"host-name": "pnode28",
"host-ip": "10.14.253.38",
"host-port": "10050"
},
{
"link": "https://zabbix.example.com/tr_events.php?triggerid=42667&eventid=8819711",
"status": "OK",
"action-id": "7",
"alert-subject": "Resolved in 1m 0s: High CPU utilization (over 90% for 5m)",
"alert-message": "Problem has been resolved at 16:49:44 on 2024.02.08\r\nProblem name: High CPU utilization (over 90% for 5m)\r\nProblem duration: 1m 0s\r\nHost: pnode28\r\nSeverity: Warning\r\nOriginal problem ID: 8819711\r\n",
"event-id": "8819711",
"event-name": "High CPU utilization (over 90% for 5m)",
"event-nseverity": "2",
"event-opdata": "Current utilization: 70.9 %",
"event-recovery-date": "2024.02.08",
"event-recovery-time": "16:49:44",
"event-duration": "1m 0s",
"event-recovery-name": "High CPU utilization (over 90% for 5m)",
"event-recovery-status": "RESOLVED",
"event-recovery-tags": "Application:CPU",
"event-severity": "Warning",
"host-name": "pnode28",
"host-ip": "10.14.253.38",
"host-port": "10050"
}
]
}
}

0
app/modules/__init__.py Normal file
View File

311
app/modules/alertmanager.py Normal file
View File

@@ -0,0 +1,311 @@
"""
Модуль для обработки алертов из AlertManager.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Tuple, Optional
from jinja2 import Environment, FileSystemLoader
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from app.models.alertmanager import PrometheusAlert
from app.core.groups import groups_config
from app.core.metrics import metrics
from app.core.jira_utils import create_jira_ticket_from_alert
from app.core.jira_mapping import jira_mapping_manager
from app.core.utils import check_stop_words, add_spaces_to_alert_name, truncate_message
from app.core.messenger_factory import MessengerFactory
from app.core.button_utils import convert_telegram_buttons_to_dict
logger = logging.getLogger(__name__)
def _get_status_icons(status: str, severity: Optional[str]) -> Tuple[str, str, str]:
"""
Получить иконки и название статуса в зависимости от статуса и серьезности алерта.
Args:
status: Статус алерта (firing, resolved, critical).
severity: Уровень серьезности.
Returns:
Кортеж (alert_icon, status_icon, status_name).
"""
if status == "critical" or severity == "critical":
return ("🔴", "💀", "Бросаем все и чиним")
elif status == "firing" or severity == "firing" or severity == "warning":
return ("🟡", "🔥", "Что-то сломалось")
elif status == "resolved":
return ("🟢", "", "Работает")
else:
return ("🔸", "", status)
async def send(
k8s_cluster: str,
group_name: str,
thread_id: int,
alert: PrometheusAlert,
messenger: Optional[str] = None
) -> None:
"""
Отправить алерт из AlertManager в мессенджер.
Args:
k8s_cluster: Имя Kubernetes кластера.
group_name: Имя группы из конфигурации.
thread_id: ID треда в группе (0 для основной группы).
alert: Данные алерта из AlertManager.
messenger: Тип мессенджера (опционально, если не указан - используется из конфигурации группы).
Raises:
ValueError: Если группа не найдена в конфигурации или алерт заблокирован стоп-словами.
"""
source = "alertmanager"
# Увеличиваем счетчик полученных сообщений
metrics.increment_total_message(source, k8s_cluster, group_name, thread_id)
# Проверяем стоп-слова
alert_name = alert.commonLabels.get("alertname", "")
if alert_name and check_stop_words(alert_name):
logger.info(f"Алерт '{alert_name}' заблокирован стоп-словами")
metrics.increment_reject_message(source, k8s_cluster, group_name, thread_id)
return # Не отправляем сообщение
# Получаем конфигурацию группы
group_config = await groups_config.get_group_config(group_name, messenger)
if group_config is None:
raise ValueError(f"Группа '{group_name}' не найдена в конфигурации")
messenger_type = group_config.get("messenger", "telegram")
chat_id = group_config.get("chat_id")
group_thread_id = group_config.get("thread_id", 0)
# Используем thread_id из параметра, если указан, иначе из конфигурации группы
final_thread_id = thread_id if thread_id > 0 else group_thread_id
# Создаем клиент мессенджера
messenger_client = MessengerFactory.create_from_config(group_config)
# Проверяем поддержку тредов
if not messenger_client.supports_threads and final_thread_id > 0:
logger.warning(f"Мессенджер '{messenger_type}' не поддерживает треды, thread_id будет проигнорирован")
final_thread_id = None
elif final_thread_id == 0:
final_thread_id = None
# Формируем сообщение
message, buttons = render_message(k8s_cluster, group_name, thread_id, alert)
# Обрезаем сообщение если оно слишком длинное
message = truncate_message(message)
# Создаем Jira тикет, если включено и нужно
jira_issue_key = None
from app.core.config import get_settings
settings = get_settings()
if settings.jira_enabled:
should_create_ticket = (
(settings.jira_create_on_alert and alert.status != "resolved") or
(settings.jira_create_on_resolved and alert.status == "resolved")
)
if should_create_ticket:
try:
jira_issue_key = await create_jira_ticket_from_alert(
alert=alert,
source=source,
k8s_cluster=k8s_cluster
)
if jira_issue_key:
# Добавляем кнопку Jira в сообщение
jira_button = _create_jira_button(jira_issue_key, settings)
if jira_button:
if buttons:
# Добавляем кнопку к существующим кнопкам
new_buttons = buttons.inline_keyboard.copy()
new_buttons.append([jira_button])
buttons = InlineKeyboardMarkup(new_buttons)
else:
# Если кнопок еще нет, создаем новую клавиатуру
buttons = InlineKeyboardMarkup([[jira_button]])
except Exception as e:
logger.error(f"Ошибка создания Jira тикета: {e}")
# Преобразуем кнопки в универсальный формат
buttons_dict = convert_telegram_buttons_to_dict(buttons)
# Отправляем сообщение
success = await messenger_client.send_message(
chat_id=chat_id,
text=message,
thread_id=final_thread_id,
reply_markup=buttons_dict,
disable_web_page_preview=True,
parse_mode="HTML"
)
if success:
metrics.increment_sent_message(source, k8s_cluster, group_name, thread_id)
# Увеличиваем счетчики в зависимости от статуса
severity = alert.commonLabels.get("severity", "")
if alert.status == "firing" or severity in ["firing", "warning"]:
metrics.increment_firing_message(source, k8s_cluster, group_name, thread_id)
elif alert.status == "critical" or severity == "critical":
metrics.increment_critical_message(source, k8s_cluster, group_name, thread_id)
elif alert.status == "resolved":
metrics.increment_resolved_message(source, k8s_cluster, group_name, thread_id)
else:
metrics.increment_error_message(source, k8s_cluster, group_name, thread_id)
raise Exception(f"Ошибка отправки сообщения в {group_config.get('messenger', 'unknown')}")
def _create_jira_button(issue_key: str, settings) -> Optional[InlineKeyboardButton]:
"""
Создать кнопку для ссылки на Jira тикет.
Args:
issue_key: Ключ тикета Jira (например, "MON-123").
settings: Настройки приложения.
Returns:
InlineKeyboardButton с ссылкой на тикет или None.
"""
if not issue_key or not settings.jira_url:
return None
jira_url = f"{settings.jira_url}/browse/{issue_key}"
return InlineKeyboardButton(f"📋 Jira: {issue_key}", url=jira_url)
def render_message(
k8s_cluster: str,
group_name: str,
thread_id: int,
alert: PrometheusAlert
) -> Tuple[str, Optional[InlineKeyboardMarkup]]:
"""
Сформировать сообщение и кнопки для мессенджера из алерта AlertManager.
Args:
k8s_cluster: Имя Kubernetes кластера.
group_name: Имя группы.
thread_id: ID треда в группе.
alert: Данные алерта из AlertManager.
Returns:
Кортеж (message, buttons).
"""
message_dict = {}
# Обрабатываем аннотации
another_annotations = ""
runbook_url = ""
for key, value in alert.commonAnnotations.items():
if key == "summary":
message_dict['summary'] = value.rstrip()
elif key == "description":
message_dict['description'] = value.rstrip()
elif key == "runbook_url":
message_dict['runbook_url'] = value
runbook_url = value
else:
another_annotations += f"<b>{key}</b>: {value}\n"
message_dict['another_annotations'] = another_annotations
# Обрабатываем метки
another_labels = ""
severity = ""
alertname = ""
for key, value in alert.commonLabels.items():
if key == "alertname":
alertname = add_spaces_to_alert_name(value)
message_dict['alertname'] = alertname
elif key == "severity":
message_dict['severity'] = value
severity = value
elif key in [
"daemonset", "statefulset", "replicaset", "job_name", "To", "integration",
"condition", "reason", "alertstate", "clustername", "namespace", "node",
"persistentvolumeclaim", "service", "container", "endpoint", "instance",
"job", "prometheus", "pod", "deployment", "metrics_path", "grpc_method",
"grpc_service", "uid"
]:
# Маппинг ключей для шаблона
if key == "namespace":
message_dict['ns'] = value
else:
message_dict[key] = value
else:
another_labels += f"{key}: {value}\n"
message_dict['another_labels'] = another_labels
# Получаем иконки статуса
alert_icon, status_icon, status_name = _get_status_icons(alert.status, severity)
message_dict['alert_icon'] = alert_icon
message_dict['status_icon'] = status_icon
message_dict['status_name'] = status_name
# Рендерим шаблон
from app.core.config import get_settings
settings = get_settings()
environment = Environment(loader=FileSystemLoader(settings.templates_path))
template = environment.get_template("alertmanager.tmpl")
message = template.render(message_dict)
# Формируем кнопки
buttons = render_buttons(k8s_cluster, runbook_url, alert.status)
logger.info("Сообщение AlertManager сформировано")
return message, buttons
def render_buttons(
k8s_cluster: str,
runbook_url: str,
alert_status: str
) -> Optional[InlineKeyboardMarkup]:
"""
Сформировать кнопки для сообщения мессенджера.
Args:
k8s_cluster: Имя Kubernetes кластера.
runbook_url: URL runbook с решением проблемы.
alert_status: Статус алерта.
Returns:
InlineKeyboardMarkup с кнопками или None.
"""
from app.core.config import get_settings
settings = get_settings()
buttons = []
try:
# Кнопки для мониторинга Kubernetes
grafana_url = settings.get_k8s_grafana_url(k8s_cluster)
prometheus_url = settings.get_k8s_prometheus_url(k8s_cluster)
alertmanager_url = settings.get_k8s_alertmanager_url(k8s_cluster)
buttons.append([
InlineKeyboardButton("Grafana", url=grafana_url),
InlineKeyboardButton("Prometheus", url=prometheus_url),
InlineKeyboardButton("Alertmanager", url=alertmanager_url)
])
except ValueError as e:
logger.warning(f"Не удалось сформировать URL для Kubernetes: {e}")
# Кнопка runbook (только для активных алертов)
if runbook_url and alert_status != "resolved":
buttons.append([InlineKeyboardButton("Вариант решения проблемы...", url=runbook_url)])
markup = InlineKeyboardMarkup(buttons) if buttons else None
logger.debug("Кнопки AlertManager сгенерированы")
return markup

261
app/modules/grafana.py Normal file
View File

@@ -0,0 +1,261 @@
"""
Модуль для обработки алертов из Grafana.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Tuple, Optional, Dict, Any
from jinja2 import Environment, FileSystemLoader
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from app.models.grafana import GrafanaAlert
from app.core.groups import groups_config
from app.core.metrics import metrics
from app.core.jira_utils import create_jira_ticket_from_alert
from app.core.utils import truncate_message
from app.core.messenger_factory import MessengerFactory
from app.core.button_utils import convert_telegram_buttons_to_dict
logger = logging.getLogger(__name__)
def _get_status_icons(state: str) -> Tuple[str, str, str]:
"""
Получить иконки и название статуса в зависимости от состояния алерта.
Args:
state: Состояние алерта из Grafana.
Returns:
Кортеж (alert_icon, status_icon, status_name).
"""
status_map = {
"alerting": ("🔴", "💀", "Бросаем все и чиним"),
"ok": ("🟢", "", "Заработало"),
"paused": ("🟡", "🐢", "Пауза? Серьезно?!"),
"pending": ("🟠", "🤷", "Что-то начинается..."),
"no_data": ("🔵", "🥴", "Данные куда-то пропали"),
}
return status_map.get(state, ("🔸", "", state))
async def send(group_name: str, thread_id: int, alert: GrafanaAlert, messenger: Optional[str] = None) -> None:
"""
Отправить алерт из Grafana в мессенджер.
Args:
group_name: Имя группы из конфигурации.
thread_id: ID треда в группе (0 для основной группы).
alert: Данные алерта из Grafana.
messenger: Тип мессенджера (опционально, если не указан - используется из конфигурации группы).
Raises:
ValueError: Если группа не найдена в конфигурации.
"""
source = "grafana"
k8s_cluster = ""
# Увеличиваем счетчик полученных сообщений
metrics.increment_total_message(source, k8s_cluster, group_name, thread_id)
# Получаем конфигурацию группы
group_config = await groups_config.get_group_config(group_name, messenger)
if group_config is None:
raise ValueError(f"Группа '{group_name}' не найдена в конфигурации")
messenger_type = group_config.get("messenger", "telegram")
chat_id = group_config.get("chat_id")
group_thread_id = group_config.get("thread_id", 0)
# Используем thread_id из параметра, если указан, иначе из конфигурации группы
final_thread_id = thread_id if thread_id > 0 else group_thread_id
# Создаем клиент мессенджера
messenger_client = MessengerFactory.create_from_config(group_config)
# Проверяем поддержку тредов
if not messenger_client.supports_threads and final_thread_id > 0:
logger.warning(f"Мессенджер '{messenger_type}' не поддерживает треды, thread_id будет проигнорирован")
final_thread_id = None
elif final_thread_id == 0:
final_thread_id = None
# Формируем сообщение
message, buttons = render_message(group_name, thread_id, alert)
# Обрезаем сообщение если оно слишком длинное
message = truncate_message(message)
# Создаем Jira тикет, если включено и нужно
jira_issue_key = None
from app.core.config import get_settings
settings = get_settings()
if settings.jira_enabled:
should_create_ticket = (
(settings.jira_create_on_alert and alert.state == "alerting") or
(settings.jira_create_on_resolved and alert.state == "ok")
)
if should_create_ticket:
try:
jira_issue_key = await create_jira_ticket_from_alert(
alert=alert,
source=source,
k8s_cluster=k8s_cluster
)
if jira_issue_key:
# Добавляем кнопку Jira в сообщение
jira_button = _create_jira_button(jira_issue_key, settings)
if jira_button:
if buttons:
# Добавляем кнопку к существующим кнопкам
new_buttons = buttons.inline_keyboard.copy()
new_buttons.append([jira_button])
buttons = InlineKeyboardMarkup(new_buttons)
else:
# Если кнопок еще нет, создаем новую клавиатуру
buttons = InlineKeyboardMarkup([[jira_button]])
except Exception as e:
logger.error(f"Ошибка создания Jira тикета: {e}")
# Преобразуем кнопки в универсальный формат
buttons_dict = convert_telegram_buttons_to_dict(buttons)
# Отправляем сообщение
success = await messenger_client.send_message(
chat_id=chat_id,
text=message,
thread_id=final_thread_id,
reply_markup=buttons_dict,
disable_web_page_preview=True,
parse_mode="HTML"
)
if success:
metrics.increment_sent_message(source, k8s_cluster, group_name, thread_id)
# Увеличиваем счетчики в зависимости от состояния
if alert.state == "alerting":
metrics.increment_firing_message(source, k8s_cluster, group_name, thread_id)
elif alert.state == "ok":
metrics.increment_resolved_message(source, k8s_cluster, group_name, thread_id)
else:
metrics.increment_error_message(source, k8s_cluster, group_name, thread_id)
raise Exception(f"Ошибка отправки сообщения в {group_config.get('messenger', 'unknown')}")
def _create_jira_button(issue_key: str, settings) -> Optional[InlineKeyboardButton]:
"""
Создать кнопку для ссылки на Jira тикет.
Args:
issue_key: Ключ тикета Jira (например, "MON-123").
settings: Настройки приложения.
Returns:
InlineKeyboardButton с ссылкой на тикет или None.
"""
if not issue_key or not settings.jira_url:
return None
jira_url = f"{settings.jira_url}/browse/{issue_key}"
return InlineKeyboardButton(f"📋 Jira: {issue_key}", url=jira_url)
def render_message(group_name: str, thread_id: int, alert: GrafanaAlert) -> Tuple[str, InlineKeyboardMarkup]:
"""
Сформировать сообщение и кнопки для мессенджера из алерта Grafana.
Args:
group_name: Имя группы.
thread_id: ID треда в группе.
alert: Данные алерта из Grafana.
Returns:
Кортеж (message, buttons).
"""
# Получаем иконки статуса
alert_icon, status_icon, status_name = _get_status_icons(alert.state)
# Формируем словарь для шаблона
message_dict = {
'state': alert.state,
'alert_icon': alert_icon,
'status_icon': status_icon,
'status_name': status_name,
'title': alert.title,
'message': alert.message or '',
'ruleid': alert.ruleId,
'rulename': alert.ruleName,
'orgid': alert.orgId,
'dashboardid': alert.dashboardId,
'panelid': alert.panelId,
}
# Обрабатываем evalMatches
labels_text = ""
for eval_match in alert.evalMatches:
labels_text += f"<b>{eval_match.metric}:</b> {eval_match.value}\n"
message_dict['labels'] = labels_text
# Обрабатываем теги
tags_text = ""
for key, value in alert.tags.items():
tags_text += f"<b>{key}:</b> {value}\n"
message_dict['tags'] = tags_text
# Получаем URL дашборда
dashboard_url = alert.ruleUrl.split("?")[0] if alert.ruleUrl else ""
rule_url = alert.ruleUrl
# Рендерим шаблон
from app.core.config import get_settings
settings = get_settings()
environment = Environment(loader=FileSystemLoader(settings.templates_path))
template = environment.get_template("grafana.tmpl")
message = template.render(message_dict)
# Формируем кнопки
buttons = render_buttons(dashboard_url, rule_url)
logger.info("Сообщение Grafana сформировано")
return message, buttons
def render_buttons(dashboard_url: str, rule_url: str) -> InlineKeyboardMarkup:
"""
Сформировать кнопки для сообщения мессенджера.
Args:
dashboard_url: URL дашборда Grafana.
rule_url: URL правила алерта.
Returns:
InlineKeyboardMarkup с кнопками.
"""
from app.core.config import get_settings
settings = get_settings()
buttons = []
# Главная кнопка Grafana
if settings.grafana_url:
buttons.append([InlineKeyboardButton("Графана", url=settings.grafana_url)])
# Кнопки дашборда и алерта
row_buttons = []
if dashboard_url:
row_buttons.append(InlineKeyboardButton("Дашборд", url=dashboard_url))
if rule_url:
row_buttons.append(InlineKeyboardButton("Алерт", url=rule_url))
if row_buttons:
buttons.append(row_buttons)
markup = InlineKeyboardMarkup(buttons) if buttons else None
logger.debug("Кнопки Grafana сгенерированы")
return markup

252
app/modules/zabbix.py Normal file
View File

@@ -0,0 +1,252 @@
"""
Модуль для обработки алертов из Zabbix.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Tuple, Optional
from jinja2 import Environment, FileSystemLoader
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from app.models.zabbix import ZabbixAlert
from app.core.groups import groups_config
from app.core.metrics import metrics
from app.core.jira_utils import create_jira_ticket_from_alert
from app.core.utils import truncate_message
from app.core.messenger_factory import MessengerFactory
from app.core.button_utils import convert_telegram_buttons_to_dict
logger = logging.getLogger(__name__)
def _get_status_icons(severity: Optional[str], status: str) -> Tuple[str, str, str]:
"""
Получить иконки и название статуса в зависимости от серьезности и статуса алерта.
Args:
severity: Уровень серьезности (Disaster, High, Warning, Average, Information).
status: Статус события (OK, PROBLEM).
Returns:
Кортеж (alert_icon, status_icon, status_name).
"""
severity = severity or "Information"
status_map = {
("Disaster", "PROBLEM"): ("🔴", "💀", "Катастрофа. Бросаем все и чиним."),
("Disaster", "OK"): ("🟢", "💀", "Катастрофы избежали. Все работает. Пошли смотреть логи!"),
("High", "PROBLEM"): ("🟠", "😡", "Оперативно реагируем, диагностируем и чиним."),
("High", "OK"): ("🟢", "😡", "Отреагировали. Продиагностировали и починили."),
("Warning", "PROBLEM"): ("🟡", "🤔", "Ой. Что-то сломалось! Пойдем посмотрим?!"),
("Warning", "OK"): ("🟢", "🤔", "Посмотрели. Доламывать не стали. Починили."),
("Average", "PROBLEM"): ("🔵", "😒", "Ну такое себе. Но можно глянуть..."),
("Average", "OK"): ("🟢", "😒", "Пока ничего критичного. Само починилось"),
("Information", "PROBLEM"): ("🟣", "👻", "Ничего критичного. Просто информирую"),
("Information", "OK"): ("🟢", "👻", "Все молодцы. IT + DevOps = 🤝"),
}
return status_map.get((severity, status), ("🔸", "", severity))
async def send(group_name: str, thread_id: int, alert: ZabbixAlert, messenger: Optional[str] = None) -> None:
"""
Отправить алерт из Zabbix в мессенджер.
Args:
group_name: Имя группы из конфигурации.
thread_id: ID треда в группе (0 для основной группы).
alert: Данные алерта из Zabbix.
messenger: Тип мессенджера (опционально, если не указан - используется из конфигурации группы).
Raises:
ValueError: Если группа не найдена в конфигурации.
"""
source = "zabbix"
k8s_cluster = ""
# Увеличиваем счетчик полученных сообщений
metrics.increment_total_message(source, k8s_cluster, group_name, thread_id)
# Получаем конфигурацию группы
group_config = await groups_config.get_group_config(group_name, messenger)
if group_config is None:
raise ValueError(f"Группа '{group_name}' не найдена в конфигурации")
messenger_type = group_config.get("messenger", "telegram")
chat_id = group_config.get("chat_id")
group_thread_id = group_config.get("thread_id", 0)
# Используем thread_id из параметра, если указан, иначе из конфигурации группы
final_thread_id = thread_id if thread_id > 0 else group_thread_id
# Создаем клиент мессенджера
messenger_client = MessengerFactory.create_from_config(group_config)
# Проверяем поддержку тредов
if not messenger_client.supports_threads and final_thread_id > 0:
logger.warning(f"Мессенджер '{messenger_type}' не поддерживает треды, thread_id будет проигнорирован")
final_thread_id = None
elif final_thread_id == 0:
final_thread_id = None
# Формируем сообщение
message, buttons = render_message(group_name, thread_id, alert)
# Обрезаем сообщение если оно слишком длинное
message = truncate_message(message)
# Создаем Jira тикет, если включено и нужно
jira_issue_key = None
from app.core.config import get_settings
settings = get_settings()
if settings.jira_enabled:
should_create_ticket = (
(settings.jira_create_on_alert and alert.status == "PROBLEM") or
(settings.jira_create_on_resolved and alert.status == "OK")
)
if should_create_ticket:
try:
jira_issue_key = await create_jira_ticket_from_alert(
alert=alert,
source=source,
k8s_cluster=k8s_cluster
)
if jira_issue_key:
# Добавляем кнопку Jira в сообщение
jira_button = _create_jira_button(jira_issue_key, settings)
if jira_button:
if buttons:
# Добавляем кнопку к существующим кнопкам
new_buttons = buttons.inline_keyboard.copy()
new_buttons.append([jira_button])
buttons = InlineKeyboardMarkup(new_buttons)
else:
# Если кнопок еще нет, создаем новую клавиатуру
buttons = InlineKeyboardMarkup([[jira_button]])
except Exception as e:
logger.error(f"Ошибка создания Jira тикета: {e}")
# Преобразуем кнопки в универсальный формат
buttons_dict = convert_telegram_buttons_to_dict(buttons)
# Отправляем сообщение
success = await messenger_client.send_message(
chat_id=chat_id,
text=message,
thread_id=final_thread_id,
reply_markup=buttons_dict,
disable_web_page_preview=True,
parse_mode="HTML"
)
if success:
metrics.increment_sent_message(source, k8s_cluster, group_name, thread_id)
# Увеличиваем счетчики в зависимости от статуса
if alert.status == "PROBLEM":
metrics.increment_firing_message(source, k8s_cluster, group_name, thread_id)
elif alert.status == "OK":
metrics.increment_resolved_message(source, k8s_cluster, group_name, thread_id)
else:
metrics.increment_error_message(source, k8s_cluster, group_name, thread_id)
raise Exception(f"Ошибка отправки сообщения в {group_config.get('messenger', 'unknown')}")
def _create_jira_button(issue_key: str, settings) -> Optional[InlineKeyboardButton]:
"""
Создать кнопку для ссылки на Jira тикет.
Args:
issue_key: Ключ тикета Jira (например, "MON-123").
settings: Настройки приложения.
Returns:
InlineKeyboardButton с ссылкой на тикет или None.
"""
if not issue_key or not settings.jira_url:
return None
jira_url = f"{settings.jira_url}/browse/{issue_key}"
return InlineKeyboardButton(f"📋 Jira: {issue_key}", url=jira_url)
def render_message(group_name: str, thread_id: int, alert: ZabbixAlert) -> Tuple[str, InlineKeyboardMarkup]:
"""
Сформировать сообщение и кнопки для мессенджера из алерта Zabbix.
Args:
group_name: Имя группы.
thread_id: ID треда в группе.
alert: Данные алерта из Zabbix.
Returns:
Кортеж (message, buttons).
"""
# Получаем иконки статуса
severity = alert.event_severity or "Information"
alert_icon, status_icon, status_name = _get_status_icons(severity, alert.status)
# Формируем словарь для шаблона
message_dict = {
'state': severity,
'alert_icon': alert_icon,
'status_icon': status_icon,
'status_name': status_name,
'title': alert.event_name,
'subject': alert.alert_subject,
'message': alert.alert_message,
'message_data': alert.event_opdata or '',
'label_date': alert.event_recovery_date or '',
'label_time': alert.event_recovery_time or '',
'label_duration': alert.event_duration or '',
'label_host': alert.host_name,
'label_ip': alert.host_ip,
'label_port': alert.host_port,
}
# Рендерим шаблон
from app.core.config import get_settings
settings = get_settings()
environment = Environment(loader=FileSystemLoader(settings.templates_path))
template = environment.get_template("zabbix.tmpl")
message = template.render(message_dict)
# Формируем кнопки
alert_url_path = alert.link.split("/")[-1] if alert.link else ""
buttons = render_buttons(alert_url_path)
logger.info("Сообщение Zabbix сформировано")
return message, buttons
def render_buttons(alert_url_path: str) -> InlineKeyboardMarkup:
"""
Сформировать кнопки для сообщения мессенджера.
Args:
alert_url_path: Путь к событию в Zabbix.
Returns:
InlineKeyboardMarkup с кнопками.
"""
from app.core.config import get_settings
settings = get_settings()
buttons = []
if settings.zabbix_url:
# Кнопка Zabbix
buttons.append([InlineKeyboardButton("Заббикс", url=settings.zabbix_url)])
# Кнопка перехода к алерту
if alert_url_path:
alert_url = f"{settings.zabbix_url}/{alert_url_path}"
buttons.append([InlineKeyboardButton("Перейти к алерту", url=alert_url)])
markup = InlineKeyboardMarkup(buttons) if buttons else None
logger.debug("Кнопки Zabbix сгенерированы")
return markup

View File

@@ -0,0 +1,24 @@
{
"monitoring": {
"messenger": "telegram",
"chat_id": -123456789,
"thread_id": 0,
"config": {}
},
"alerts_max": {
"messenger": "max",
"chat_id": "123456789",
"thread_id": 0,
"config": {
"access_token": "your_access_token_here",
"api_version": "5.131"
}
},
"kubernetes": {
"messenger": "telegram",
"chat_id": -12345678,
"thread_id": 0,
"config": {}
}
}

29
docker-compose.yaml Normal file
View File

@@ -0,0 +1,29 @@
version: "3.8"
services:
message-gateway:
build:
context: .
dockerfile: Dockerfile
container_name: message-gateway
ports:
- "8000:8000"
volumes:
- ./app:/app/app
- ./config:/app/config
- ./templates:/app/templates
env_file:
- .env
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- message-gateway-network
networks:
message-gateway-network:
driver: bridge

32
docs/README.md Normal file
View File

@@ -0,0 +1,32 @@
# Документация Message Gateway
Добро пожаловать в документацию проекта Message Gateway!
## Содержание
### Основная документация
- [Настройка ботов](bots.md) - как создать ботов в Telegram и MAX/VK
- [Конфигурация групп](groups.md) - как настроить группы мессенджеров
- [Шаблоны сообщений](templates.md) - как работают шаблоны Jinja2
- [Конфигурация Jira Mapping](jira-mapping.md) - как настроить маппинг алертов в Jira
- [Отправка сообщений](messaging.md) - как работает отправка сообщений
### Настройка систем мониторинга
- [Настройка Grafana](monitoring/grafana.md) - как настроить Grafana для отправки алертов
- [Настройка Zabbix](monitoring/zabbix.md) - как настроить Zabbix для отправки алертов
- [Настройка AlertManager](monitoring/alertmanager.md) - как настроить AlertManager для отправки алертов
### API документация
- [API для управления группами](api/groups.md) - управление группами мессенджеров через API
- [API для отправки сообщений](api/message.md) - отправка простых сообщений в мессенджеры
- [API для проверки здоровья](api/health.md) - проверка здоровья и готовности приложения
- [API для отладки](api/debug.md) - сохранение JSON данных для отладки
- [Декораторы API](api/decorators.md) - декораторы для авторизации и скрытия эндпоинтов
---
**Автор:** Сергей Антропов
**Сайт:** https://devops.org.ru

105
docs/api/debug.md Normal file
View File

@@ -0,0 +1,105 @@
# API для отладки
**Важно:** Эндпоинт `/api/v1/debug/dump` скрыт из Swagger UI для безопасности, но остается доступным для прямых запросов. Используйте его только для отладки.
## Сохранение JSON данных для отладки
```bash
POST /api/v1/debug/dump
Content-Type: application/json
{
"test": "data",
"timestamp": "2024-01-01T00:00:00Z",
"source": "grafana",
"alert": {
"title": "Test alert",
"state": "alerting"
}
}
```
**Пример ответа:**
```json
{
"status": "ok",
"message": "Данные сохранены в dump.json",
"data": {
"test": "data",
"timestamp": "2024-01-01T00:00:00Z",
"source": "grafana",
"alert": {
"title": "Test alert",
"state": "alerting"
}
}
}
```
## Примечания
- Используется для отладки входящих webhook запросов
- Данные сохраняются в файл `/app/app/dump.json` в контейнере
- Файл можно просмотреть через `make shell` или `make docker shell`
- Полезно для анализа формата данных от различных источников
- **Эндпоинт скрыт из Swagger UI** с помощью декоратора `@hide_from_api` для безопасности
- Эндпоинт не требует авторизации (API ключ не нужен)
- Используется только для внутренней отладки
## Примеры использования
### Сохранение данных из Grafana
```bash
curl -X POST "http://your-gateway-url/api/v1/debug/dump" \
-H "Content-Type: application/json" \
-d '{
"source": "grafana",
"alert": {
"title": "Test alert",
"state": "alerting"
}
}'
```
### Сохранение данных из Zabbix
```bash
curl -X POST "http://your-gateway-url/api/v1/debug/dump" \
-H "Content-Type: application/json" \
-d '{
"source": "zabbix",
"event": {
"event-id": "8819711",
"event-name": "High CPU utilization",
"status": "PROBLEM"
}
}'
```
### Сохранение данных из AlertManager
```bash
curl -X POST "http://your-gateway-url/api/v1/debug/dump" \
-H "Content-Type: application/json" \
-d '{
"source": "alertmanager",
"status": "firing",
"commonLabels": {
"alertname": "HighCPUUsage",
"severity": "critical"
}
}'
```
## Просмотр сохраненных данных
```bash
# Через make shell
make shell
cat /app/app/dump.json
# Или напрямую через docker
docker exec -it message-gateway cat /app/app/dump.json
```

128
docs/api/decorators.md Normal file
View File

@@ -0,0 +1,128 @@
# Декораторы API
В проекте Message Gateway используются декораторы для упрощения работы с API и обеспечения безопасности.
## Декораторы
### `@require_api_key`
Декоратор для пометки эндпоинта как требующего API ключ для авторизации.
**Использование:**
```python
from app.core.auth import require_api_key, require_api_key_dependency
@require_api_key
@router.post("/endpoint", dependencies=[require_api_key_dependency])
async def my_endpoint(request: Request, ...):
...
```
**Примечания:**
- Декоратор используется только для пометки функции
- Фактическая проверка API ключа выполняется через `dependencies=[require_api_key_dependency]`
- Это обеспечивает отображение замочка в Swagger UI
- API ключ настраивается в переменной окружения `API_KEY` в файле `.env`
- API ключ передается в заголовке `X-API-Key`
**Пример:**
```python
from app.core.auth import require_api_key, require_api_key_dependency
from fastapi import APIRouter, Request, Body
router = APIRouter(prefix="/api/v1/groups", tags=["groups"])
@require_api_key
@router.post(
"",
name="Создать группу",
dependencies=[require_api_key_dependency]
)
async def create_group(
request: Request,
body: CreateGroupRequest = Body(...)
) -> Dict[str, Any]:
...
```
### `@hide_from_api`
Декоратор для скрытия эндпоинта из API документации (Swagger UI).
**Использование:**
```python
from app.core.auth import hide_from_api
@hide_from_api
@router.post("/debug/dump", include_in_schema=False)
async def debug_endpoint(...):
...
```
**Примечания:**
- Декоратор помечает функцию как скрытую от API
- Эндпоинт все еще будет работать, но не будет отображаться в Swagger UI
- Рекомендуется использовать вместе с параметром `include_in_schema=False` в декораторе route для надежного скрытия
- Используется для скрытия внутренних эндпоинтов от публичной документации
- Полезно для отладочных эндпоинтов и административных функций
**Пример:**
```python
from app.core.auth import hide_from_api
from fastapi import APIRouter, Body
router = APIRouter(prefix="/api/v1/debug", tags=["debug"])
@hide_from_api
@router.post(
"/dump",
name="JSON Debug dump",
include_in_schema=False, # Скрываем эндпоинт из Swagger UI
response_model=Dict[str, Any]
)
async def dump_request(
dump: Dict[str, Any] = Body(...)
) -> Dict[str, Any]:
...
```
## Комбинирование декораторов
Декораторы можно комбинировать для более сложных сценариев:
```python
from app.core.auth import require_api_key, require_api_key_dependency, hide_from_api
@require_api_key
@hide_from_api
@router.post(
"/internal/admin",
dependencies=[require_api_key_dependency],
include_in_schema=False
)
async def internal_admin_endpoint(request: Request, ...):
...
```
В этом примере:
- Эндпоинт требует API ключ для доступа
- Эндпоинт скрыт из Swagger UI
- Эндпоинт доступен только для администраторов с правильным API ключом
## Примечания
- Декораторы применяются снизу вверх (от функции к верхнему декоратору)
- Порядок декораторов важен: сначала применяется декоратор route (`@router.post`), затем декораторы авторизации и скрытия
- Декораторы не изменяют функциональность эндпоинта, они только добавляют метаданные или проверки
- Все декораторы определены в модуле `app.core.auth`
## Дополнительная информация
- Подробная документация по API ключам: см. [API для управления группами](groups.md)
- Подробная документация по отладочным эндпоинтам: см. [API для отладки](debug.md)
---
**Автор:** Сергей Антропов
**Сайт:** https://devops.org.ru

164
docs/api/groups.md Normal file
View File

@@ -0,0 +1,164 @@
# API для управления группами
## Получение списка поддерживаемых мессенджеров
```bash
GET /api/v1/groups/messengers
```
**Пример ответа:**
```json
{
"status": "ok",
"messengers": [
{
"type": "telegram",
"name": "Telegram",
"supports_threads": true,
"enabled": true
},
{
"type": "max",
"name": "MAX/VK",
"supports_threads": false,
"enabled": false
}
]
}
```
## Получение списка групп
```bash
# Без API ключа (только названия)
GET /api/v1/groups
# С API ключом (полная информация)
GET /api/v1/groups
Headers: X-API-Key: your_api_key_here
```
**Пример ответа (без API ключа):**
```json
{
"status": "ok",
"groups": [
{"name": "monitoring", "chat_id": null},
{"name": "alerts", "chat_id": null}
],
"count": 2
}
```
**Пример ответа (с API ключом):**
```json
{
"status": "ok",
"groups": [
{
"name": "monitoring",
"messenger": "telegram",
"chat_id": -1001234567890,
"thread_id": 0
},
{
"name": "alerts_max",
"messenger": "max",
"chat_id": "123456789",
"thread_id": null
}
],
"count": 2
}
```
## Создание группы
```bash
POST /api/v1/groups
Headers:
Content-Type: application/json
X-API-Key: your_api_key_here
{
"group_name": "monitoring",
"messenger": "telegram",
"chat_id": -1001234567890,
"thread_id": 0
}
```
**Пример запроса для MAX/VK:**
```bash
POST /api/v1/groups
Headers:
Content-Type: application/json
X-API-Key: your_api_key_here
{
"group_name": "max_alerts",
"messenger": "max",
"chat_id": "123456789",
"thread_id": 0,
"config": {
"access_token": "your_max_access_token"
}
}
```
**Пример ответа:**
```json
{
"status": "ok",
"message": "Группа 'monitoring' создана с мессенджером 'telegram' и ID -1001234567890"
}
```
## Обновление группы
```bash
PUT /api/v1/groups/{group_name}
Headers:
Content-Type: application/json
X-API-Key: your_api_key_here
{
"messenger": "telegram",
"chat_id": -1001234567891,
"thread_id": 0
}
```
**Пример ответа:**
```json
{
"status": "ok",
"message": "Группа 'monitoring' обновлена"
}
```
## Удаление группы
```bash
DELETE /api/v1/groups/{group_name}
Headers: X-API-Key: your_api_key_here
```
**Пример ответа:**
```json
{
"status": "ok",
"message": "Группа 'monitoring' удалена"
}
```
## Примечания
- Требуется API ключ в заголовке X-API-Key для всех операций, кроме получения списка групп (без API ключа возвращаются только названия)
- Группы сохраняются в `config/groups.json`
- Chat ID можно получить, добавив бота в группу и отправив сообщение
- Используйте `@userinfobot` в Telegram для получения Chat ID группы
- Для MAX/VK используйте peer_id (может быть строкой или числом)
- Thread ID поддерживается только для Telegram (для MAX/VK игнорируется)
- Дополнительная конфигурация (config) может содержать параметры для конкретного мессенджера (например, access_token для MAX/VK)

63
docs/api/health.md Normal file
View File

@@ -0,0 +1,63 @@
# API для проверки здоровья
## Проверка здоровья и готовности приложения
```bash
GET /api/v1/health
```
**Пример ответа (здорово):**
```json
{
"status": "healthy",
"state": "online",
"telegram_bot_configured": true,
"groups_config_available": true
}
```
**Пример ответа (не готово):**
```json
{
"status": "not_ready",
"state": "online",
"checks": {
"telegram_bot_configured": false,
"groups_config_available": true
}
}
```
## Примечания
- Не требует аутентификации
- Возвращает 503 если приложение не готово
- Используется Kubernetes для liveness и readiness probes
- Настройте liveness и readiness probes в kubernetes.yaml
- Приложение считается готовым, если все проверки пройдены
## Проверки
- `telegram_bot_configured` - наличие токена Telegram (TELEGRAM_BOT_TOKEN)
- `groups_config_available` - доступность файла конфигурации групп (config/groups.json)
## Использование в Kubernetes
Настройте liveness и readiness probes в `kubernetes.yaml`:
```yaml
livenessProbe:
httpGet:
path: /api/v1/health
port: 8000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /api/v1/health
port: 8000
initialDelaySeconds: 5
periodSeconds: 5
```

188
docs/api/message.md Normal file
View File

@@ -0,0 +1,188 @@
# API для отправки сообщений
**Важно:** Все эндпоинты для отправки сообщений требуют API ключ в заголовке `X-API-Key`.
## Аутентификация
Все эндпоинты требуют API ключ в заголовке запроса:
```bash
X-API-Key: your_api_key_here
```
API ключ настраивается в переменной окружения `API_KEY` в файле `.env`.
## Отправка текстового сообщения
```bash
POST /api/v1/message/text
Content-Type: application/json
X-API-Key: your_api_key_here
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"text": "<b>Критическое уведомление</b>\n\nСистема недоступна!\n\n<i>Время: 2024-02-08 16:49:44</i>",
"parse_mode": "HTML",
"disable_web_page_preview": false
}
```
**Пример ответа:**
```json
{
"status": "ok",
"message": "Сообщение отправлено в чат monitoring, тред 0"
}
```
**Поддерживаемые режимы парсинга:**
- `HTML` - HTML форматирование (рекомендуется)
- `Markdown` - Markdown форматирование
- `MarkdownV2` - Markdown V2 форматирование
**Примеры форматирования:**
- HTML: `<b>жирный</b>`, `<i>курсив</i>`, `<code>код</code>`
- Markdown: `**жирный**`, `*курсив*`, `` `код` ``
## Отправка фото
```bash
POST /api/v1/message/photo
Content-Type: application/json
X-API-Key: your_api_key_here
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"photo": "https://grafana.example.com/render/dashboard-solo?panelId=1&width=1000&height=500",
"caption": "<b>График CPU</b>\n\n<i>Время: 2024-02-08 16:49:44</i>",
"parse_mode": "HTML"
}
```
**Пример ответа:**
```json
{
"status": "ok",
"message": "Фото отправлено в чат monitoring, тред 0"
}
```
**Примечания:**
- Фото может быть передано через URL (автоматически загрузится) или как файл
- Поддерживаются форматы: JPG, PNG, GIF, WebP
- Максимальный размер файла: 10 МБ (Telegram), зависит от мессенджера
- Для отправки графиков из Grafana используйте URL рендеринга дашбордов
## Отправка видео
```bash
POST /api/v1/message/video
Content-Type: application/json
X-API-Key: your_api_key_here
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"video": "https://example.com/recording.webm",
"caption": "<b>Запись работы системы</b>\n\n<i>Длительность: 60 сек</i>",
"parse_mode": "HTML",
"duration": 60,
"width": 1920,
"height": 1080
}
```
**Пример ответа:**
```json
{
"status": "ok",
"message": "Видео отправлено в чат monitoring, тред 0"
}
```
**Примечания:**
- Видео может быть передано через URL (автоматически загрузится) или как файл
- Поддерживаются форматы: MP4, WebM, MOV (зависит от мессенджера)
- Максимальный размер файла: 50 МБ (Telegram), зависит от мессенджера
- Длительность, ширина и высота опциональны, но рекомендуются для лучшего отображения
## Отправка аудио
```bash
POST /api/v1/message/audio
Content-Type: application/json
X-API-Key: your_api_key_here
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"audio": "https://example.com/notification.ogg",
"caption": "<b>Аудио уведомление</b>\n\n<i>Система работает нормально</i>",
"parse_mode": "HTML",
"duration": 30,
"performer": "System Notification",
"title": "Alert Notification"
}
```
**Пример ответа:**
```json
{
"status": "ok",
"message": "Аудио отправлено в чат monitoring, тред 0"
}
```
**Примечания:**
- Аудио может быть передано через URL (автоматически загрузится) или как файл
- Поддерживаются форматы: MP3, OGG, WAV, M4A (зависит от мессенджера)
- Максимальный размер файла: 50 МБ (Telegram), зависит от мессенджера
- Исполнитель и название опциональны, но улучшают отображение в Telegram
## Отправка документа
```bash
POST /api/v1/message/document
Content-Type: application/json
X-API-Key: your_api_key_here
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"document": "https://example.com/report.xlsx",
"caption": "<b>Отчет за неделю</b>\n\n<i>Дата: 2024-02-08</i>",
"parse_mode": "HTML",
"filename": "report_2024-02-08.xlsx"
}
```
**Пример ответа:**
```json
{
"status": "ok",
"message": "Документ отправлен в чат monitoring, тред 0"
}
```
**Примечания:**
- Документ может быть передан через URL (автоматически загрузится) или как файл
- Поддерживаются все форматы файлов (PDF, DOCX, XLSX, TXT, и т.д.)
- Максимальный размер файла: 50 МБ (Telegram), зависит от мессенджера
- Имя файла опционально, если не указано - используется имя из URL
- Для отправки отчетов используйте прямые ссылки на файлы
## Параметры запроса
Все endpoints поддерживают параметр `messenger` (опционально):
- `?messenger=telegram` - использовать Telegram
- `?messenger=max` - использовать MAX/VK
- Если не указан, используется мессенджер из конфигурации группы
## Общие параметры
- `tg_group` - имя группы из конфигурации (config/groups.json)
- `tg_thread_id` - ID треда в группе (0 для основной группы, поддерживается только для Telegram)
- `messenger` - тип мессенджера (опционально, `telegram`, `max`, по умолчанию используется из конфигурации группы)

91
docs/bots.md Normal file
View File

@@ -0,0 +1,91 @@
# Настройка ботов
## Создание бота в Telegram
1. **Создание бота через BotFather:**
- Откройте Telegram и найдите бота [@BotFather](https://t.me/BotFather)
- Отправьте команду `/newbot`
- Следуйте инструкциям BotFather:
- Введите имя бота (например, "My Monitoring Bot")
- Введите username бота (должен заканчиваться на `bot`, например, "my_monitoring_bot")
- BotFather предоставит вам **токен бота** (например, `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`)
2. **Настройка бота:**
- Сохраните токен бота в переменную окружения `TELEGRAM_BOT_TOKEN`
- Добавьте бота в группу Telegram, куда нужно отправлять сообщения
- Сделайте бота администратором группы (опционально, но рекомендуется для полной функциональности)
3. **Получение Chat ID группы:**
- Добавьте бота [@userinfobot](https://t.me/userinfobot) в группу
- Отправьте любое сообщение в группе
- @userinfobot покажет Chat ID группы (например, `-1001234567890`)
- Сохраните Chat ID в конфигурацию групп (`config/groups.json`)
4. **Получение Thread ID (для тредов):**
- Создайте тред в группе Telegram
- Используйте бота [@userinfobot](https://t.me/userinfobot) или API Telegram для получения Thread ID
- Thread ID можно получить через API Telegram: `getUpdates` или `getChat`
**Пример конфигурации для Telegram:**
```json
{
"monitoring": {
"messenger": "telegram",
"chat_id": -1001234567890,
"thread_id": 0,
"config": {}
}
}
```
## Создание бота в MAX/VK
1. **Создание приложения в VK:**
- Перейдите на [VK Developers](https://dev.vk.com/)
- Создайте новое приложение:
- Нажмите "Создать приложение"
- Выберите тип приложения: "Веб-сайт" или "Standalone"
- Введите название приложения
- Подтвердите создание
2. **Получение Access Token:**
- В настройках приложения найдите раздел "Токены"
- Создайте новый токен:
- Выберите права доступа (scope):
- `messages` - для отправки сообщений
- `photos` - для отправки фотографий
- `docs` - для отправки документов
- Скопируйте полученный токен
- Сохраните токен в переменную окружения `MAX_ACCESS_TOKEN`
3. **Получение Peer ID (ID чата):**
- Peer ID можно получить через API VK: `messages.getConversations`
- Для группы: Peer ID = `2000000000 + group_id`
- Для беседы: Peer ID можно получить через `messages.getChat`
4. **Настройка приложения:**
- Установите переменные окружения:
- `MAX_ACCESS_TOKEN` - токен доступа
- `MAX_API_VERSION` - версия API (по умолчанию `5.131`)
- `MAX_ENABLED=true` - включить поддержку MAX/VK
**Пример конфигурации для MAX/VK:**
```json
{
"max_alerts": {
"messenger": "max",
"chat_id": "123456789",
"thread_id": null,
"config": {
"access_token": "your_access_token_here",
"api_version": "5.131"
}
}
}
```
**Примечания:**
- MAX/VK не поддерживает треды (thread_id всегда `null`)
- Peer ID может быть как числом, так и строкой
- Для работы с группами VK требуется, чтобы бот был добавлен в группу и имел права администратора

195
docs/groups.md Normal file
View File

@@ -0,0 +1,195 @@
# Конфигурация групп
## Формат конфигурации
Конфигурация групп хранится в файле `config/groups.json`.
**Пример конфигурации:**
```json
{
"monitoring": {
"messenger": "telegram",
"chat_id": -1001234567890,
"thread_id": 0,
"config": {}
},
"alerts_max": {
"messenger": "max",
"chat_id": "123456789",
"thread_id": null,
"config": {
"access_token": "optional_override_max_token",
"api_version": "5.131"
}
}
}
```
**Поля конфигурации:**
- `messenger` - тип мессенджера (`telegram`, `max`)
- `chat_id` - ID чата (может быть числом для Telegram или строкой для MAX/VK)
- `thread_id` - ID треда в группе (только для Telegram, `0` для основной группы, `null` для MAX/VK)
- `config` - дополнительная конфигурация для мессенджера (например, `access_token` для MAX/VK)
## Управление группами через API
Управление группами доступно через REST API с защитой API ключом.
### Получение списка поддерживаемых мессенджеров
```bash
GET /api/v1/groups/messengers
```
**Пример ответа:**
```json
{
"status": "ok",
"messengers": [
{
"type": "telegram",
"name": "Telegram",
"supports_threads": true,
"enabled": true
},
{
"type": "max",
"name": "MAX/VK",
"supports_threads": false,
"enabled": false
}
]
}
```
### Получение списка групп
```bash
# Без API ключа (только названия)
GET /api/v1/groups
# С API ключом (полная информация)
GET /api/v1/groups
Headers: X-API-Key: your_api_key_here
```
**Пример ответа (без API ключа):**
```json
{
"status": "ok",
"groups": [
{"name": "monitoring", "chat_id": null},
{"name": "alerts", "chat_id": null}
],
"count": 2
}
```
**Пример ответа (с API ключом):**
```json
{
"status": "ok",
"groups": [
{
"name": "monitoring",
"messenger": "telegram",
"chat_id": -1001234567890,
"thread_id": 0
},
{
"name": "alerts_max",
"messenger": "max",
"chat_id": "123456789",
"thread_id": null
}
],
"count": 2
}
```
### Создание группы
```bash
POST /api/v1/groups
Headers:
Content-Type: application/json
X-API-Key: your_api_key_here
{
"group_name": "monitoring",
"messenger": "telegram",
"chat_id": -1001234567890,
"thread_id": 0
}
```
**Пример запроса для MAX/VK:**
```bash
POST /api/v1/groups
Headers:
Content-Type: application/json
X-API-Key: your_api_key_here
{
"group_name": "max_alerts",
"messenger": "max",
"chat_id": "123456789",
"thread_id": 0,
"config": {
"access_token": "your_max_access_token"
}
}
```
**Пример ответа:**
```json
{
"status": "ok",
"message": "Группа 'monitoring' создана с мессенджером 'telegram' и ID -1001234567890"
}
```
### Обновление группы
```bash
PUT /api/v1/groups/{group_name}
Headers:
Content-Type: application/json
X-API-Key: your_api_key_here
{
"messenger": "telegram",
"chat_id": -1001234567891,
"thread_id": 0
}
```
**Пример ответа:**
```json
{
"status": "ok",
"message": "Группа 'monitoring' обновлена"
}
```
### Удаление группы
```bash
DELETE /api/v1/groups/{group_name}
Headers: X-API-Key: your_api_key_here
```
**Пример ответа:**
```json
{
"status": "ok",
"message": "Группа 'monitoring' удалена"
}
```
**API ключ для управления группами:**
- Настраивается в переменной окружения `API_KEY`
- Без API ключа можно только просматривать названия групп
- С API ключом доступны все операции (создание, обновление, удаление)
- API ключ передается в заголовке: `X-API-Key: your_api_key_here`

113
docs/jira-mapping.md Normal file
View File

@@ -0,0 +1,113 @@
# Конфигурация Jira Mapping
## Формат конфигурации
Конфигурация маппинга Jira хранится в файле `config/jira_mapping.json`. Она определяет, какие тикеты создавать в Jira на основе алертов.
**Пример конфигурации:**
```json
{
"alertmanager": {
"default_project": "MON",
"default_assignee": null,
"default_issue_type": "Bug",
"default_priority": "High",
"mappings": [
{
"conditions": {
"severity": "critical",
"namespace": "production"
},
"project": "MON",
"assignee": "devops-team@example.com",
"issue_type": "Bug",
"priority": "Highest",
"labels": ["critical", "production", "alertmanager"]
}
]
},
"grafana": {
"default_project": "MON",
"default_assignee": null,
"default_issue_type": "Bug",
"default_priority": "High",
"mappings": []
},
"zabbix": {
"default_project": "MON",
"default_assignee": null,
"default_issue_type": "Bug",
"default_priority": "High",
"mappings": []
}
}
```
## Условия маппинга
Условия маппинга определяют, когда использовать конкретный маппинг. Поддерживаются следующие условия:
### Для AlertManager:
- `status` - статус алерта (firing, resolved)
- `severity` - критичность (critical, warning, info)
- `namespace` - namespace Kubernetes
- `alertname` - название алерта
- Любые другие метки из `commonLabels`
### Для Grafana:
- `state` - состояние алерта (alerting, ok, paused, pending, no_data)
- `ruleName` - название правила
- `tags` - теги алерта (объект с ключами и значениями)
### Для Zabbix:
- `status` - статус (PROBLEM, OK)
- `event-severity` - серьезность события
- `event-name` - название события
- `host-name` - имя хоста
**Примеры условий:**
```json
{
"conditions": {
"severity": "critical",
"namespace": "production"
}
}
```
```json
{
"conditions": {
"state": "alerting",
"tags": {
"environment": "production"
}
}
}
```
**Приоритет маппингов:**
1. Проверяются все маппинги в порядке их определения
2. Используется первый маппинг, условия которого совпадают с данными алерта
3. Если маппинг не найден, используются значения по умолчанию
## Автоматическое создание тикетов
При получении алерта, если интеграция с Jira включена и выполнены условия для создания тикета:
1. Приложение проверяет конфигурацию маппинга
2. Находит подходящий маппинг на основе условий алерта
3. Создает тикет в Jira с указанными параметрами
4. Добавляет кнопку в Telegram сообщение со ссылкой на созданный тикет
**Настройка переменных окружения:**
- `JIRA_ENABLED=true` - включить интеграцию с Jira
- `JIRA_URL=https://jira.example.com` - URL Jira сервера
- `JIRA_EMAIL=user@example.com` - Email пользователя Jira
- `JIRA_API_TOKEN=your_jira_api_token_here` - API токен Jira
- `JIRA_PROJECT_KEY=MON` - Ключ проекта Jira по умолчанию
- `JIRA_DEFAULT_ASSIGNEE=user@example.com` - Исполнитель по умолчанию
- `JIRA_DEFAULT_ISSUE_TYPE=Bug` - Тип задачи по умолчанию
- `JIRA_CREATE_ON_ALERT=true` - Создавать тикет при алерте
- `JIRA_CREATE_ON_RESOLVED=false` - Создавать тикет при resolved

54
docs/messaging.md Normal file
View File

@@ -0,0 +1,54 @@
# Отправка сообщений
## Как работает отправка
1. **Получение алерта:**
- Система мониторинга (Grafana, Zabbix, AlertManager) отправляет webhook на эндпоинт Message Gateway
- Message Gateway валидирует данные алерта через Pydantic модели
- Увеличивается счетчик полученных сообщений
2. **Обработка алерта:**
- Проверяются стоп-слова (если алерт заблокирован, сообщение не отправляется)
- Загружается конфигурация группы из `config/groups.json`
- Определяется мессенджер (Telegram, MAX/VK) из конфигурации группы или параметра запроса
3. **Формирование сообщения:**
- Загружается шаблон сообщения из `templates/`
- Шаблон рендерится с данными алерта через Jinja2
- Формируются кнопки для перехода к Grafana/Prometheus/Jira
4. **Создание Jira тикета (опционально):**
- Если Jira включена и выполнены условия, создается тикет в Jira
- Загружается маппинг из `config/jira_mapping.json`
- Находится подходящий маппинг на основе условий алерта
- Создается тикет с описанием из шаблона `jira_common.tmpl`
- Добавляется кнопка для перехода к тикету в Jira
5. **Отправка в мессенджер:**
- Создается клиент мессенджера через `MessengerFactory`
- Проверяется поддержка тредов (для MAX/VK треды не поддерживаются)
- Сообщение отправляется в мессенджер через клиент
- Увеличивается счетчик отправленных сообщений
## Поддержка мессенджеров
### Telegram
- ✅ Полная поддержка текстовых сообщений
- ✅ Поддержка медиа (фото, видео, аудио, документы)
- ✅ Поддержка тредов (threads)
- ✅ Поддержка кнопок (InlineKeyboardMarkup)
- ✅ Поддержка HTML и Markdown форматирования
### MAX/VK
- ✅ Поддержка текстовых сообщений
- ✅ Поддержка медиа (фото, видео, аудио, документы)
- ❌ Треды не поддерживаются (thread_id игнорируется)
- ✅ Поддержка кнопок (в формате VK API)
- ✅ Конвертация HTML в формат VK
**Различия в отправке:**
- Telegram использует Bot API, MAX/VK использует VK API
- Telegram поддерживает треды, MAX/VK - нет
- Формат кнопок различается (Telegram - InlineKeyboardMarkup, MAX/VK - JSON)
- HTML форматирование конвертируется в формат VK для MAX/VK

View File

@@ -0,0 +1,121 @@
# Настройка AlertManager
## Создание конфигурации AlertManager
1. **Открытие конфигурации:**
- Откройте файл конфигурации AlertManager (обычно `alertmanager.yml`)
- Добавьте webhook receiver:
```yaml
receivers:
- name: 'message-gateway'
webhook_configs:
- url: 'http://your-gateway-url/api/v1/alertmanager/{k8s_cluster}/{group_name}/{thread_id}'
send_resolved: true
http_config:
bearer_token: 'optional-bearer-token'
```
2. **Настройка Route:**
- В конфигурации AlertManager добавьте route:
```yaml
route:
group_by: ['alertname', 'cluster', 'service']
group_wait: 10s
group_interval: 10s
repeat_interval: 12h
receiver: 'message-gateway'
routes:
- match:
severity: critical
receiver: 'message-gateway'
continue: true
- match:
severity: warning
receiver: 'message-gateway'
```
3. **Применение конфигурации:**
- Сохраните файл конфигурации
- Перезапустите AlertManager или перезагрузите конфигурацию:
```bash
curl -X POST http://alertmanager:9093/-/reload
```
## Пример конфигурации
**Полный пример `alertmanager.yml`:**
```yaml
global:
resolve_timeout: 5m
route:
group_by: ['alertname', 'cluster', 'service']
group_wait: 10s
group_interval: 10s
repeat_interval: 12h
receiver: 'message-gateway'
routes:
- match:
severity: critical
receiver: 'message-gateway'
continue: true
- match:
severity: warning
receiver: 'message-gateway'
receivers:
- name: 'message-gateway'
webhook_configs:
- url: 'http://message-gateway.example.com/api/v1/alertmanager/production/monitoring/0'
send_resolved: true
```
**Пример URL для webhook:**
```
http://message-gateway.example.com/api/v1/alertmanager/production/monitoring/0
```
Где:
- `production` - имя Kubernetes кластера (используется для формирования URL к Grafana/Prometheus)
- `monitoring` - имя группы из `config/groups.json`
- `0` - ID треда (0 для основной группы, поддерживается только для Telegram)
## Формат данных
AlertManager отправляет данные в следующем формате:
```json
{
"status": "firing",
"externalURL": "http://alertmanager.example.com",
"commonLabels": {
"alertname": "HighCPUUsage",
"severity": "critical",
"namespace": "production",
"pod": "app-deployment-7d8f9b4c5-abc123",
"container": "app-container"
},
"commonAnnotations": {
"summary": "High CPU usage detected in production namespace",
"description": "CPU usage is above 90% for 5 minutes on pod app-deployment-7d8f9b4c5-abc123",
"runbook_url": "https://wiki.example.com/runbooks/high-cpu-usage"
}
}
```
## Поддерживаемые статусы
- `firing` - алерт сработал
- `resolved` - алерт разрешен
## Примечания
- Не требует авторизации (API ключ не нужен)
- Если Jira включена, будет автоматически создан тикет в Jira (внутренний процесс)
- В сообщении будет добавлена кнопка для перехода к тикету в Jira (если поддерживается мессенджером)
- Алерты могут быть заблокированы стоп-словами (настройка в config/stop_words.txt)
- URL к Grafana/Prometheus/AlertManager формируется на основе имени кластера
- Thread ID поддерживается только для Telegram (для MAX/VK игнорируется)

View File

@@ -0,0 +1,91 @@
# Настройка Grafana
## Создание Webhook Notification Channel
1. **Открытие настроек:**
- Откройте Grafana и перейдите в раздел "Alerting" → "Notification channels"
- Нажмите "New channel"
2. **Настройка webhook:**
- Выберите тип "Webhook"
- Заполните форму:
- **Name:** Message Gateway
- **Type:** webhook
- **URL:** `http://your-gateway-url/api/v1/grafana/{group_name}/{thread_id}`
- Где `{group_name}` - имя группы из `config/groups.json` (например, "monitoring")
- Где `{thread_id}` - ID треда (0 для основной группы, поддерживается только для Telegram)
- **HTTP Method:** POST
- **Send on all alerts:** включено
- **Include image:** опционально
- Сохраните настройки
3. **Настройка Alert Rule:**
- Создайте или откройте существующее правило алерта
- В разделе "Notifications" выберите созданный канал "Message Gateway"
- Сохраните правило
4. **Тестирование:**
- Создайте тестовый алерт в Grafana
- Проверьте, что сообщение пришло в Telegram/MAX
**Пример URL для webhook:**
```
http://message-gateway.example.com/api/v1/grafana/monitoring/0
```
## Пример конфигурации
**Notification Channel в Grafana:**
- **Name:** Message Gateway
- **Type:** Webhook
- **URL:** `http://message-gateway.example.com/api/v1/grafana/monitoring/0`
- **HTTP Method:** POST
- **Send on all alerts:** Yes
- **Include image:** No
**Alert Rule:**
- В разделе "Notifications" выберите "Message Gateway"
- Сохраните правило
## Формат данных
Grafana отправляет данные в следующем формате:
```json
{
"title": "[Alerting] High CPU Usage",
"ruleId": 674180201771804383,
"ruleName": "High CPU Usage Alert",
"state": "alerting",
"evalMatches": [
{
"value": 95.5,
"metric": "cpu_usage_percent",
"tags": {"host": "server01", "instance": "production"}
}
],
"orgId": 1,
"dashboardId": 123,
"panelId": 456,
"tags": {"severity": "critical", "environment": "production"},
"ruleUrl": "http://grafana.cism-ms.ru/alerting/list",
"message": "CPU usage is above 90% threshold for more than 5 minutes"
}
```
## Поддерживаемые состояния
- `alerting` - алерт сработал
- `ok` - алерт разрешен
- `paused` - алерт приостановлен
- `pending` - алерт в ожидании
- `no_data` - нет данных
## Примечания
- Не требует авторизации (API ключ не нужен)
- Если Jira включена, будет автоматически создан тикет в Jira (внутренний процесс)
- В сообщении будет добавлена кнопка для перехода к тикету в Jira (если поддерживается мессенджером)
- URL правила алерта будет добавлен в сообщение
- Thread ID поддерживается только для Telegram (для MAX/VK игнорируется)

107
docs/monitoring/zabbix.md Normal file
View File

@@ -0,0 +1,107 @@
# Настройка Zabbix
## Создание Media Type
1. **Открытие настроек:**
- Откройте Zabbix и перейдите в раздел "Administration" → "Media types"
- Нажмите "Create media type"
2. **Настройка Media Type:**
- Заполните форму:
- **Name:** Message Gateway
- **Type:** Webhook
- **Script name:** message-gateway
- **Script parameters:**
```
{ALERT.SENDTO}
{ALERT.SUBJECT}
{ALERT.MESSAGE}
```
- Сохраните настройки
3. **Создание Action:**
- Перейдите в раздел "Configuration" → "Actions" → "Trigger actions"
- Нажмите "Create action"
- Заполните форму:
- **Name:** Send to Message Gateway
- **Conditions:** выберите условия (например, "Trigger severity" = "High")
- **Operations:** добавьте операцию "Send to Message Gateway"
- **Recovery operations:** добавьте операцию "Send to Message Gateway"
- Сохраните настройки
4. **Настройка User Media:**
- Откройте пользователя в Zabbix
- В разделе "Media" добавьте новый media:
- **Type:** Message Gateway
- **Send to:** `monitoring/0` (формат: `{group_name}/{thread_id}`)
- **When active:** 1-7,00:00-24:00
- Сохраните настройки
## Пример конфигурации
**Media Type в Zabbix:**
- **Name:** Message Gateway
- **Type:** Webhook
- **Script name:** message-gateway
- **Script parameters:**
```
{ALERT.SENDTO}
{ALERT.SUBJECT}
{ALERT.MESSAGE}
```
**Action:**
- **Name:** Send to Message Gateway
- **Conditions:** Trigger severity = High
- **Operations:** Send to Message Gateway
- **Recovery operations:** Send to Message Gateway
**User Media:**
- **Type:** Message Gateway
- **Send to:** `monitoring/0`
- **When active:** 1-7,00:00-24:00
## Формат данных
Zabbix отправляет данные в следующем формате:
```json
{
"link": "https://zabbix.example.com/tr_events.php?triggerid=42667&eventid=8819711",
"status": "PROBLEM",
"action-id": "7",
"alert-subject": "Problem: High CPU utilization (over 90% for 5m)",
"alert-message": "Problem started at 16:48:44 on 2024.02.08",
"event-id": "8819711",
"event-name": "High CPU utilization (over 90% for 5m)",
"event-nseverity": "2",
"event-opdata": "Current utilization: 95.2 %",
"event-severity": "Warning",
"host-name": "pnode28",
"host-ip": "10.14.253.38",
"host-port": "10050"
}
```
## Поддерживаемые статусы
- `PROBLEM` - проблема обнаружена
- `OK` - проблема разрешена
## Уровни серьезности
- `Disaster` - катастрофа
- `High` - высокая
- `Warning` - предупреждение
- `Average` - средняя
- `Information` - информация
## Примечания
- Не требует авторизации (API ключ не нужен)
- Если Jira включена, будет автоматически создан тикет в Jira (внутренний процесс)
- В сообщении будет добавлена кнопка для перехода к тикету в Jira (если поддерживается мессенджером)
- Ссылка на событие Zabbix будет добавлена в сообщение
- Thread ID поддерживается только для Telegram (для MAX/VK игнорируется)
- Можно настроить фильтрацию по серьезности события

226
docs/templates.md Normal file
View File

@@ -0,0 +1,226 @@
# Шаблоны сообщений
## Структура шаблонов
Шаблоны сообщений находятся в папке `templates/` и используют синтаксис Jinja2. Шаблоны разделены на две категории:
1. **Шаблоны для мессенджеров** - форматирование сообщений для отправки в Telegram, MAX/VK
2. **Шаблоны для Jira** - форматирование описания тикетов в Jira
## Шаблоны для мессенджеров
### Grafana (`templates/grafana.tmpl`)
Шаблон для форматирования алертов из Grafana:
```jinja2
{{ alert_icon }} <b>{{ title }}</b>
{% if status_name is defined %}{{ status_icon }} <b>Критичность</b>: {{ status_name }}{% endif %}
<b>Подробнее:</b>
{{ message }}
{% if labels %}
👉 <b>Переменные:</b>
{{ labels }}{% endif %}
{% if tags and state !='ok' %}💻 <b>Ответственные:</b>
{{ tags }}{% endif %}
```
**Доступные переменные:**
- `alert_icon` - иконка алерта (🔴, 🟢, 🟡, 🟠, 🔵)
- `status_icon` - иконка статуса (💀, ✅, 🐢, 🤷, 🥴)
- `status_name` - название статуса
- `title` - заголовок алерта
- `message` - сообщение алерта
- `state` - состояние алерта (alerting, ok, paused, pending, no_data)
- `labels` - метрики (evalMatches)
- `tags` - теги алерта
### Zabbix (`templates/zabbix.tmpl`)
Шаблон для форматирования алертов из Zabbix:
```jinja2
{{ alert_icon }} <b>{{ title }}</b>
{% if status_name is defined %}{{ status_icon }} <b>Критичность</b>: {{ status_name }} ({{ state }}){% endif %}
<b>Кратко:</b>
{{ subject }}
<b>Подробнее:</b>
{{ message }}
👉 <b>Значение:</b>
{{ message_data }}
🌐 <b>Сеть:</b>
Хост: {{ label_host }}
IP: {{ label_ip }}
Порт: {{ label_port }}
```
**Доступные переменные:**
- `alert_icon` - иконка алерта
- `status_icon` - иконка статуса
- `status_name` - название статуса
- `title` - название события
- `subject` - тема алерта
- `message` - сообщение алерта
- `message_data` - дополнительные данные события
- `label_host` - имя хоста
- `label_ip` - IP адрес хоста
- `label_port` - порт хоста
### AlertManager (`templates/alertmanager.tmpl`)
Шаблон для форматирования алертов из AlertManager:
```jinja2
{{ alert_icon }} <b>{{ alertname }}</b>
{% if summary is defined %}{{ summary }}{% endif %}
{% if status_name is defined %}{{ status_icon }} <b>Критичность</b>: {{ status_name }}{% endif %}{% if description is defined %}
<b>Подробнее:</b>
{{ description }}{% endif %}{% if another_annotations != "" %}
{{ another_annotations }}{% endif %}
{% if clustername is defined %}👉 <b>Kubernetes:</b>
<b>Кластер</b>: {{ clustername }}{% if node is defined %}
<b>Нода</b>: {{ node }}{% endif %}{% if ns is defined %}
<b>Неймспейс</b>: {{ ns }}{% endif %}{% if deployment is defined %}
<b>Деплоймент</b>: {{ deployment }}{% endif %}{% if daemonset is defined %}
<b>Демонсет</b>: {{ daemonset }}{% endif %}{% if replicaset is defined %}
<b>Репликасет</b>: {{ replicaset }}{% endif %}{% if statefulset is defined %}
<b>Стейтфулсет</b>: {{ statefulset }}{% endif %}{% if container is defined %}
<b>Контейнер</b>: {{ container }}{% endif %}{% if pod is defined %}
<b>Под</b>: {{ pod }}{% endif %}{% if persistentvolumeclaim is defined %}
<b>PVC</b>: {{ persistentvolumeclaim }}{% endif %}{% if job_name is defined %}
<b>Имя джобы</b>: {{ job_name }}{% endif %}{% if job is defined %}
<b>Джоба</b>: {{ job }}{% endif %}{% if reason is defined %}
<b>Причина</b>: {{ reason }}{% endif %}{% if endpoint is defined %}
<b>Эндпоинт</b>: {{ endpoint }}{% endif %}{% if instance is defined %}
<b>Инстанс</b>: {{ instance }}{% endif %}{% if condition is defined %}
<b>Состояние</b>: {{ condition }}{% endif %}{% if reason is defined %}
<b>Причина</b>: {{ reason }}{% endif %}{% endif %}
{% if prometheus is defined %}🔍 <b>Прометей:</b>
<b>Сервер</b>: {{ prometheus }}{% if service is defined %}
<b>Сервис</b>: {{ service }}{% endif %}{% if metrics_path is defined %}
<b>Метрики</b>: {{ metrics_path }}{% endif %}{% if uid is defined %}
<b>UID</b>: {{ uid }}{% endif %}{% if integration is defined %}
<b>Integration</b>: {{ integration }}{% endif %}{% if To is defined %}
<b>To</b>: {{ integration }}{% endif %}{% endif %}
{% if another_labels != "" %}
🤷 <b>Разное</b>:
{{ another_labels }}{% endif %}
```
**Доступные переменные:**
- `alert_icon` - иконка алерта
- `status_icon` - иконка статуса
- `status_name` - название статуса
- `alertname` - название алерта
- `summary` - краткое описание
- `description` - подробное описание
- `another_annotations` - дополнительные аннотации
- `clustername` - имя кластера Kubernetes
- `node` - имя ноды
- `ns` - namespace
- `deployment` - имя deployment
- `pod` - имя pod
- `container` - имя контейнера
- И другие метки из `commonLabels`
## Шаблоны для Jira
### Общий шаблон (`templates/jira_common.tmpl`)
Шаблон для форматирования описания тикетов в Jira:
```jinja2
**Источник:** {{ source }}
{% if k8s_cluster %}**Kubernetes кластер:** {{ k8s_cluster }}{% endif %}
---
## Детали алерта
{% if source == "alertmanager" %}
**Статус:** {{ status }}
**Название:** {{ alertname }}
**Критичность:** {{ severity }}
{% if summary %}**Краткое описание:**
{{ summary }}
{% endif %}
{% if description %}**Подробное описание:**
{{ description }}
{% endif %}
**Метки:**
{% for key, value in common_labels.items() %}
- *{{ key }}*: {{ value }}
{% endfor %}
**Аннотации:**
{% for key, value in common_annotations.items() %}
- *{{ key }}*: {{ value }}
{% endfor %}
{% endif %}
{% if source == "grafana" %}
**Состояние:** {{ state }}
**Правило:** {{ ruleName }}
**Заголовок:** {{ title }}
{% if message %}**Сообщение:**
{{ message }}
{% endif %}
{% if tags %}**Теги:**
{% for key, value in tags.items() %}
- *{{ key }}*: {{ value }}
{% endfor %}
{% endif %}
{% if evalMatches %}**Метрики:**
{% for match in evalMatches %}
- *{{ match.metric }}*: {{ match.value }}
{% endfor %}
{% endif %}
{% endif %}
{% if source == "zabbix" %}
**Статус:** {{ status }}
**Серьезность:** {{ event_severity }}
**Событие:** {{ event_name }}
**Хост:** {{ host_name }} ({{ host_ip }}:{{ host_port }})
**Тема:**
{{ alert_subject }}
**Сообщение:**
{{ alert_message }}
{% endif %}
---
*Тикет создан автоматически системой мониторинга*
```
**Примечания:**
- Шаблон поддерживает условное форматирование в зависимости от источника алерта
- Для каждого источника доступны свои переменные
- Можно создать отдельные шаблоны для каждого источника: `jira_alertmanager.tmpl`, `jira_grafana.tmpl`, `jira_zabbix.tmpl`

57
env.example Normal file
View File

@@ -0,0 +1,57 @@
# Telegram Bot настройки
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
TELEGRAM_ENABLED=true
# MAX/VK настройки
MAX_ACCESS_TOKEN=your_max_access_token_here
MAX_API_VERSION=5.131
MAX_ENABLED=false
# Общие настройки мессенджеров
DEFAULT_MESSENGER=telegram
# API ключ для авторизации
API_KEY=your_api_key_here
# Пароль для управления группами (устаревший, используется для обратной совместимости)
GROUPS_ADMIN_PASSWORD=your_admin_password_here
# Файлы конфигурации
GROUPS_CONFIG_PATH=/app/config/groups.json
TEMPLATES_PATH=/app/templates
# Grafana настройки
GRAFANA_URL=http://grafana.example.com
# Zabbix настройки
ZABBIX_URL=https://zabbix.example.com
# Kubernetes кластер настройки
K8S_CLUSTER_GRAFANA_SUBDOMAIN=monitor.example.kube
K8S_CLUSTER_PROMETHEUS_SUBDOMAIN=prometheus.example.kube
K8S_CLUSTER_ALERTMANAGER_SUBDOMAIN=alert.example.kube
# Prometheus Pushgateway настройки
PUSHGATEWAY_URL=http://pushgateway.example.com:9091
PUSHGATEWAY_JOB=MessageGateway
# OpenTelemetry настройки
OTEL_ENABLED=false
OTEL_SERVICE_NAME=monitoring-message-gateway
OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo.example.com:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/json
OTEL_TRACES_EXPORTER=otlp_proto_http
OTEL_EXPORTER_OTLP_INSECURE=true
OTEL_PYTHON_LOG_CORRELATION=false
# Jira настройки
JIRA_ENABLED=false
JIRA_URL=https://jira.example.com
JIRA_EMAIL=user@example.com
JIRA_API_TOKEN=your_jira_api_token_here
JIRA_PROJECT_KEY=MON
JIRA_DEFAULT_ASSIGNEE=user@example.com
JIRA_DEFAULT_ISSUE_TYPE=Bug
JIRA_MAPPING_CONFIG_PATH=/app/config/jira_mapping.json
JIRA_CREATE_ON_ALERT=true
JIRA_CREATE_ON_RESOLVED=false

340
kubernetes.yaml Normal file
View File

@@ -0,0 +1,340 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: message-gateway
---
apiVersion: v1
kind: Secret
metadata:
name: message-gateway-secret
namespace: message-gateway
type: Opaque
stringData:
telegram_bot_token: "" # Установите токен через kubectl create secret или SealedSecret
pushgateway_url: "" # URL Pushgateway (опционально)
pushgateway_job: "MessageGateway"
grafana_url: "" # URL Grafana (опционально)
zabbix_url: "" # URL Zabbix (опционально)
k8s_cluster_grafana_subdomain: "" # Поддомен Grafana для K8S кластеров (опционально)
k8s_cluster_prometheus_subdomain: "" # Поддомен Prometheus для K8S кластеров (опционально)
k8s_cluster_alertmanager_subdomain: "" # Поддомен AlertManager для K8S кластеров (опционально)
otel_enabled: "false" # Включить OpenTelemetry (true/false)
otel_service_name: "monitoring-message-gateway"
otel_exporter_otlp_endpoint: "" # Endpoint OpenTelemetry (опционально)
otel_exporter_otlp_protocol: "http/json"
otel_traces_exporter: "otlp_proto_http"
otel_exporter_otlp_insecure: "true"
otel_python_log_correlation: "false"
groups_admin_password: "" # Пароль для управления группами (опционально)
jira_enabled: "false" # Включить интеграцию с Jira (true/false)
jira_url: "" # URL Jira (опционально)
jira_email: "" # Email пользователя Jira (опционально)
jira_api_token: "" # API Token Jira (опционально)
jira_project_key: "" # Ключ проекта Jira (опционально)
jira_default_assignee: "" # Email исполнителя по умолчанию (опционально)
jira_default_issue_type: "Bug" # Тип задачи по умолчанию
jira_create_on_alert: "true" # Создавать тикет при алерте (true/false)
jira_create_on_resolved: "false" # Создавать тикет при resolved (true/false)
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: message-gateway
namespace: message-gateway
labels:
app: message-gateway
spec:
replicas: 1
selector:
matchLabels:
app: message-gateway
strategy:
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
type: RollingUpdate
template:
metadata:
name: message-gateway
labels:
app: message-gateway
spec:
imagePullSecrets:
- name: cismharbor
nodeSelector:
gpushare: "false" # Нода с ГПУ (false/true)
nodestate: "working" # Состояние ноды (working/new)
containers:
- name: message-gateway
image: hub.cism-ms.ru/library/message-gateway:v0.2.0
imagePullPolicy: "Always"
ports:
- containerPort: 8000
env:
- name: TELEGRAM_BOT_TOKEN
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: telegram_bot_token
- name: GRAFANA_URL
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: grafana_url
optional: true
- name: ZABBIX_URL
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: zabbix_url
optional: true
- name: K8S_CLUSTER_GRAFANA_SUBDOMAIN
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: k8s_cluster_grafana_subdomain
optional: true
- name: K8S_CLUSTER_PROMETHEUS_SUBDOMAIN
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: k8s_cluster_prometheus_subdomain
optional: true
- name: K8S_CLUSTER_ALERTMANAGER_SUBDOMAIN
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: k8s_cluster_alertmanager_subdomain
optional: true
- name: PUSHGATEWAY_URL
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: pushgateway_url
optional: true
- name: PUSHGATEWAY_JOB
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: pushgateway_job
optional: true
- name: OTEL_ENABLED
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: otel_enabled
optional: true
- name: OTEL_SERVICE_NAME
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: otel_service_name
optional: true
- name: OTEL_EXPORTER_OTLP_ENDPOINT
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: otel_exporter_otlp_endpoint
optional: true
- name: OTEL_EXPORTER_OTLP_PROTOCOL
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: otel_exporter_otlp_protocol
optional: true
- name: OTEL_TRACES_EXPORTER
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: otel_traces_exporter
optional: true
- name: OTEL_EXPORTER_OTLP_INSECURE
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: otel_exporter_otlp_insecure
optional: true
- name: OTEL_PYTHON_LOG_CORRELATION
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: otel_python_log_correlation
optional: true
- name: GROUPS_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: groups_admin_password
optional: true
- name: JIRA_ENABLED
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: jira_enabled
optional: true
- name: JIRA_URL
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: jira_url
optional: true
- name: JIRA_EMAIL
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: jira_email
optional: true
- name: JIRA_API_TOKEN
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: jira_api_token
optional: true
- name: JIRA_PROJECT_KEY
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: jira_project_key
optional: true
- name: JIRA_DEFAULT_ASSIGNEE
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: jira_default_assignee
optional: true
- name: JIRA_DEFAULT_ISSUE_TYPE
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: jira_default_issue_type
optional: true
- name: JIRA_CREATE_ON_ALERT
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: jira_create_on_alert
optional: true
- name: JIRA_CREATE_ON_RESOLVED
valueFrom:
secretKeyRef:
name: message-gateway-secret
key: jira_create_on_resolved
optional: true
# Liveness проба
livenessProbe:
httpGet:
path: /api/v1/health
port: 8000
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
successThreshold: 1
failureThreshold: 3
# Readiness проба
readinessProbe:
httpGet:
path: /api/v1/health/ready
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
successThreshold: 1
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 200m
memory: 256Mi
volumeMounts:
- name: groups-conf
mountPath: /app/config/groups.json
subPath: groups.json
readOnly: true
- name: jira-mapping-conf
mountPath: /app/config/jira_mapping.json
subPath: jira_mapping.json
readOnly: true
volumes:
- name: groups-conf
configMap:
name: message-gateway-groups-configmap
- name: jira-mapping-conf
configMap:
name: message-gateway-jira-mapping-configmap
optional: true
---
apiVersion: v1
kind: ConfigMap
metadata:
name: message-gateway-groups-configmap
namespace: message-gateway
data:
groups.json: |
{
"kubernetes": -1002108349725,
"monitoring": -1001997464975,
"oldmonitoring": -1001469966749
}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: message-gateway-jira-mapping-configmap
namespace: message-gateway
data:
jira_mapping.json: |
{
"alertmanager": {
"default_project": "MON",
"mappings": []
},
"grafana": {
"default_project": "MON",
"mappings": []
},
"zabbix": {
"default_project": "MON",
"mappings": []
}
}
---
apiVersion: v1
kind: Service
metadata:
name: message-gateway-service
namespace: message-gateway
labels:
app: message-gateway
spec:
type: ClusterIP
selector:
app: message-gateway
ports:
- protocol: TCP
port: 8000
targetPort: 8000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: message-gateway-ingress
namespace: message-gateway
spec:
tls:
- hosts:
- monitoring.cism-ms.ru
secretName: ru-cism-kube-certs
rules:
- host: "monitoring.cism-ms.ru"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: message-gateway-service
port:
number: 8000

123
make-wrapper.sh Executable file
View File

@@ -0,0 +1,123 @@
#!/bin/bash
# Обертка для Makefile для поддержки подкоманд с пробелами
# Использование: ./make-wrapper.sh docker build
# ./make-wrapper.sh git push origin main
# ./make-wrapper.sh k8s apply CONTEXT=prod
set -e
# Цвета для вывода
COLOR_RESET='\033[0m'
COLOR_BOLD='\033[1m'
COLOR_CYAN='\033[36m'
COLOR_YELLOW='\033[33m'
# Функция для вывода помощи
show_help() {
echo -e "${COLOR_BOLD}${COLOR_CYAN}Makefile Wrapper - Поддержка подкоманд${COLOR_RESET}"
echo ""
echo "Использование:"
echo " ./make-wrapper.sh docker build"
echo " ./make-wrapper.sh docker tag"
echo " ./make-wrapper.sh docker push"
echo " ./make-wrapper.sh git status"
echo " ./make-wrapper.sh git pull"
echo " ./make-wrapper.sh git push origin main"
echo " ./make-wrapper.sh git add file1 file2"
echo " ./make-wrapper.sh git commit \"message\""
echo " ./make-wrapper.sh k8s contexts"
echo " ./make-wrapper.sh k8s apply CONTEXT=prod"
echo " ./make-wrapper.sh env check"
echo " ./make-wrapper.sh env create"
echo ""
echo "Или используйте напрямую:"
echo " make docker CMD=build"
echo " make git CMD=push ARGS=origin main"
echo " make k8s CMD=apply CONTEXT=prod"
echo " make env CMD=check"
}
# Проверка аргументов
if [ $# -eq 0 ]; then
show_help
exit 0
fi
# Получаем команду (docker, git, k8s, env)
CMD_TYPE=$1
shift
# Обработка команд
case "$CMD_TYPE" in
docker)
if [ $# -eq 0 ]; then
make docker
else
SUB_CMD=$1
shift
make docker CMD="$SUB_CMD" "$@"
fi
;;
git)
if [ $# -eq 0 ]; then
make git
else
SUB_CMD=$1
shift
# Для git commit и git add нужны аргументы
if [ "$SUB_CMD" = "commit" ] || [ "$SUB_CMD" = "add" ]; then
ARGS="$@"
make git CMD="$SUB_CMD" ARGS="$ARGS"
else
# Для остальных команд аргументы передаются как есть
make git CMD="$SUB_CMD" ARGS="$@"
fi
fi
;;
k8s)
if [ $# -eq 0 ]; then
make k8s
else
SUB_CMD=$1
shift
# Обработка CONTEXT=value
CONTEXT_ARG=""
ARGS=""
for arg in "$@"; do
if [[ "$arg" == CONTEXT=* ]]; then
CONTEXT_ARG="$arg"
else
ARGS="$ARGS $arg"
fi
done
if [ -n "$CONTEXT_ARG" ]; then
make k8s CMD="$SUB_CMD" $CONTEXT_ARG "$ARGS"
else
make k8s CMD="$SUB_CMD" "$ARGS"
fi
fi
;;
env)
if [ $# -eq 0 ]; then
make env
else
SUB_CMD=$1
shift
# Для create-force нужно обработать
if [ "$SUB_CMD" = "create-force" ]; then
make env-create-force
else
make env CMD="$SUB_CMD" "$@"
fi
fi
;;
help|--help|-h)
show_help
exit 0
;;
*)
# Если это не подкоманда, передаем в make напрямую
make "$CMD_TYPE" "$@"
;;
esac

35
requirements.txt Normal file
View File

@@ -0,0 +1,35 @@
# Main
uvicorn[standard]==0.25.0
fastapi[all]==0.105.0
# FastApi packages
pydantic==2.5.3
pydantic-settings==2.1.0
jinja2==3.1.2
# Telegram
python-telegram-bot==20.7
# Prometheus
prometheus-client==0.19.0
# Async file operations
aiofiles==23.2.1
# Traces
opentelemetry-distro==0.43b0
opentelemetry-api==1.22.0
opentelemetry-sdk==1.22.0
opentelemetry-exporter-otlp==1.22.0
opentelemetry-instrumentation-fastapi==0.43b0
# Logging
opentelemetry-instrumentation-logging==0.43b0
# Jira
jira==3.5.2
# Testing
pytest==7.4.3
pytest-asyncio==0.21.1
httpx==0.25.2

View File

@@ -0,0 +1,43 @@
{{ alert_icon }} <b>{{ alertname }}</b>
{% if summary is defined %}{{ summary }}{% endif %}
{% if status_name is defined %}{{ status_icon }} <b>Критичность</b>: {{ status_name }}{% endif %}{% if description is defined %}
<b>Подробнее:</b>
{{ description }}{% endif %}{% if another_annotations != "" %}
{{ another_annotations }}{% endif %}
{% if clustername is defined %}👉 <b>Kubernetes:</b>
<b>Кластер</b>: {{ clustername }}{% if node is defined %}
<b>Нода</b>: {{ node }}{% endif %}{% if ns is defined %}
<b>Неймспейс</b>: {{ ns }}{% endif %}{% if deployment is defined %}
<b>Деплоймент</b>: {{ deployment }}{% endif %}{% if daemonset is defined %}
<b>Демонсет</b>: {{ daemonset }}{% endif %}{% if replicaset is defined %}
<b>Репликасет</b>: {{ replicaset }}{% endif %}{% if statefulset is defined %}
<b>Стейтфулсет</b>: {{ statefulset }}{% endif %}{% if container is defined %}
<b>Контейнер</b>: {{ container }}{% endif %}{% if pod is defined %}
<b>Под</b>: {{ pod }}{% endif %}{% if persistentvolumeclaim is defined %}
<b>PVC</b>: {{ persistentvolumeclaim }}{% endif %}{% if job_name is defined %}
<b>Имя джобы</b>: {{ job_name }}{% endif %}{% if job is defined %}
<b>Джоба</b>: {{ job }}{% endif %}{% if reason is defined %}
<b>Причина</b>: {{ reason }}{% endif %}{% if endpoint is defined %}
<b>Эндпоинт</b>: {{ endpoint }}{% endif %}{% if instance is defined %}
<b>Инстанс</b>: {{ instance }}{% endif %}{% if condition is defined %}
<b>Состояние</b>: {{ condition }}{% endif %}{% if reason is defined %}
<b>Причина</b>: {{ reason }}{% endif %}{% endif %}
{% if prometheus is defined %}🔍 <b>Прометей:</b>
<b>Сервер</b>: {{ prometheus }}{% if service is defined %}
<b>Сервис</b>: {{ service }}{% endif %}{% if metrics_path is defined %}
<b>Метрики</b>: {{ metrics_path }}{% endif %}{% if uid is defined %}
<b>UID</b>: {{ uid }}{% endif %}{% if integration is defined %}
<b>Integration</b>: {{ integration }}{% endif %}{% if To is defined %}
<b>To</b>: {{ integration }}{% endif %}{% endif %}
{% if another_labels != "" %}
🤷 <b>Разное</b>:
{{ another_labels }}{% endif %}

13
templates/grafana.tmpl Normal file
View File

@@ -0,0 +1,13 @@
{{ alert_icon }} <b>{{ title }}</b>
{% if status_name is defined %}{{ status_icon }} <b>Критичность</b>: {{ status_name }}{% endif %}
<b>Подробнее:</b>
{{ message }}
{% if labels %}
👉 <b>Переменные:</b>
{{ labels }}{% endif %}
{% if tags and state !='ok' %}💻 <b>Ответственные:</b>
{{ tags }}{% endif %}

View File

@@ -0,0 +1,71 @@
**Источник:** {{ source }}
{% if k8s_cluster %}**Kubernetes кластер:** {{ k8s_cluster }}{% endif %}
---
## Детали алерта
{% if source == "alertmanager" %}
**Статус:** {{ status }}
**Название:** {{ alertname }}
**Критичность:** {{ severity }}
{% if summary %}**Краткое описание:**
{{ summary }}
{% endif %}
{% if description %}**Подробное описание:**
{{ description }}
{% endif %}
**Метки:**
{% for key, value in common_labels.items() %}
- *{{ key }}*: {{ value }}
{% endfor %}
**Аннотации:**
{% for key, value in common_annotations.items() %}
- *{{ key }}*: {{ value }}
{% endfor %}
{% endif %}
{% if source == "grafana" %}
**Состояние:** {{ state }}
**Правило:** {{ ruleName }}
**Заголовок:** {{ title }}
{% if message %}**Сообщение:**
{{ message }}
{% endif %}
{% if tags %}**Теги:**
{% for key, value in tags.items() %}
- *{{ key }}*: {{ value }}
{% endfor %}
{% endif %}
{% if evalMatches %}**Метрики:**
{% for match in evalMatches %}
- *{{ match.metric }}*: {{ match.value }}
{% endfor %}
{% endif %}
{% endif %}
{% if source == "zabbix" %}
**Статус:** {{ status }}
**Серьезность:** {{ event_severity }}
**Событие:** {{ event_name }}
**Хост:** {{ host_name }} ({{ host_ip }}:{{ host_port }})
**Тема:**
{{ alert_subject }}
**Сообщение:**
{{ alert_message }}
{% endif %}
---
*Тикет создан автоматически системой мониторинга*

16
templates/zabbix.tmpl Normal file
View File

@@ -0,0 +1,16 @@
{{ alert_icon }} <b>{{ title }}</b>
{% if status_name is defined %}{{ status_icon }} <b>Критичность</b>: {{ status_name }} ({{ state }}){% endif %}
<b>Кратко:</b>
{{ subject }}
<b>Подробнее:</b>
{{ message }}
👉 <b>Значение:</b>
{{ message_data }}
🌐 <b>Сеть:</b>
Хост: {{ label_host }}
IP: {{ label_ip }}
Порт: {{ label_port }}