commit b90def35ede7886ed2be3598ff5699bbc39f8209 Author: Sergey Antropov Date: Wed Nov 12 20:25:11 2025 +0300 Initial commit: Message Gateway project - FastAPI приложение для отправки мониторинговых алертов в мессенджеры - Поддержка Telegram и MAX/VK - Интеграция с Grafana, Zabbix, AlertManager - Автоматическое создание тикетов в Jira - Управление группами мессенджеров через API - Декораторы для авторизации и скрытия эндпоинтов - Подробная документация в папке docs/ Автор: Сергей Антропов Сайт: https://devops.org.ru diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc63c0b --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..440f3a8 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ab6512f --- /dev/null +++ b/Makefile @@ -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=) +# ============================================================================ + +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=) +# ============================================================================ + +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=) +# ============================================================================ + +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=) +# ============================================================================ + +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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5af7ece --- /dev/null +++ b/README.md @@ -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 +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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..d424b08 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,6 @@ +""" +API модули приложения. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..3ab1e3e --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1,6 @@ +""" +API версии 1. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..269860d --- /dev/null +++ b/app/api/v1/endpoints/__init__.py @@ -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", +] \ No newline at end of file diff --git a/app/api/v1/endpoints/debug.py b/app/api/v1/endpoints/debug.py new file mode 100644 index 0000000..89077bb --- /dev/null +++ b/app/api/v1/endpoints/debug.py @@ -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)}") diff --git a/app/api/v1/endpoints/groups.py b/app/api/v1/endpoints/groups.py new file mode 100644 index 0000000..6849a23 --- /dev/null +++ b/app/api/v1/endpoints/groups.py @@ -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)}") diff --git a/app/api/v1/endpoints/health.py b/app/api/v1/endpoints/health.py new file mode 100644 index 0000000..cdf0a1b --- /dev/null +++ b/app/api/v1/endpoints/health.py @@ -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 + } diff --git a/app/api/v1/endpoints/jira.py b/app/api/v1/endpoints/jira.py new file mode 100644 index 0000000..9833618 --- /dev/null +++ b/app/api/v1/endpoints/jira.py @@ -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) diff --git a/app/api/v1/endpoints/message.py b/app/api/v1/endpoints/message.py new file mode 100644 index 0000000..195912c --- /dev/null +++ b/app/api/v1/endpoints/message.py @@ -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 + ) diff --git a/app/api/v1/endpoints/monitoring.py b/app/api/v1/endpoints/monitoring.py new file mode 100644 index 0000000..3e9882c --- /dev/null +++ b/app/api/v1/endpoints/monitoring.py @@ -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)}") + diff --git a/app/api/v1/router.py b/app/api/v1/router.py new file mode 100644 index 0000000..f32faab --- /dev/null +++ b/app/api/v1/router.py @@ -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) diff --git a/app/common/__init__.py b/app/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/common/cors.py b/app/common/cors.py new file mode 100644 index 0000000..2256907 --- /dev/null +++ b/app/common/cors.py @@ -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 добавлен") \ No newline at end of file diff --git a/app/common/logger.py b/app/common/logger.py new file mode 100644 index 0000000..6773f76 --- /dev/null +++ b/app/common/logger.py @@ -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' + ) + diff --git a/app/common/metrics.py b/app/common/metrics.py new file mode 100644 index 0000000..3dfc7d4 --- /dev/null +++ b/app/common/metrics.py @@ -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) diff --git a/app/common/telemetry.py b/app/common/telemetry.py new file mode 100644 index 0000000..a838dc8 --- /dev/null +++ b/app/common/telemetry.py @@ -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 отключен") \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..c5b6849 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1,6 @@ +""" +Общие утилиты и функции для приложения. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" diff --git a/app/core/auth.py b/app/core/auth.py new file mode 100644 index 0000000..ae8716c --- /dev/null +++ b/app/core/auth.py @@ -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 diff --git a/app/core/button_utils.py b/app/core/button_utils.py new file mode 100644 index 0000000..0b6035b --- /dev/null +++ b/app/core/button_utils.py @@ -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 + diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..f7a9b4e --- /dev/null +++ b/app/core/config.py @@ -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() diff --git a/app/core/groups.py b/app/core/groups.py new file mode 100644 index 0000000..da68447 --- /dev/null +++ b/app/core/groups.py @@ -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() diff --git a/app/core/jira_client.py b/app/core/jira_client.py new file mode 100644 index 0000000..4f164f9 --- /dev/null +++ b/app/core/jira_client.py @@ -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 + diff --git a/app/core/jira_mapping.py b/app/core/jira_mapping.py new file mode 100644 index 0000000..c93ca08 --- /dev/null +++ b/app/core/jira_mapping.py @@ -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() + diff --git a/app/core/jira_utils.py b/app/core/jira_utils.py new file mode 100644 index 0000000..d149fff --- /dev/null +++ b/app/core/jira_utils.py @@ -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 diff --git a/app/core/messenger_factory.py b/app/core/messenger_factory.py new file mode 100644 index 0000000..b5b4d01 --- /dev/null +++ b/app/core/messenger_factory.py @@ -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) + diff --git a/app/core/messengers/__init__.py b/app/core/messengers/__init__.py new file mode 100644 index 0000000..0995a5b --- /dev/null +++ b/app/core/messengers/__init__.py @@ -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", +] + diff --git a/app/core/messengers/base.py b/app/core/messengers/base.py new file mode 100644 index 0000000..8c2d5ad --- /dev/null +++ b/app/core/messengers/base.py @@ -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 + diff --git a/app/core/messengers/max.py b/app/core/messengers/max.py new file mode 100644 index 0000000..a1939c4 --- /dev/null +++ b/app/core/messengers/max.py @@ -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 формат + # Заменяем и на [bold] и [/bold] + text = text.replace("", "[bold]").replace("", "[/bold]") + text = text.replace("", "[bold]").replace("", "[/bold]") + + # Заменяем и на [italic] и [/italic] + text = text.replace("", "[italic]").replace("", "[/italic]") + text = text.replace("", "[italic]").replace("", "[/italic]") + + # Заменяем и на [code] и [/code] + text = text.replace("", "[code]").replace("", "[/code]") + + # Заменяем
 и 
на [code] и [/code] + text = text.replace("
", "[code]").replace("
", "[/code]") + + # Заменяем
и
на перенос строки + text = text.replace("
", "\n").replace("
", "\n").replace("
", "\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 + diff --git a/app/core/messengers/telegram.py b/app/core/messengers/telegram.py new file mode 100644 index 0000000..7e2e44a --- /dev/null +++ b/app/core/messengers/telegram.py @@ -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 + ) + diff --git a/app/core/metrics.py b/app/core/metrics.py new file mode 100644 index 0000000..9a536ad --- /dev/null +++ b/app/core/metrics.py @@ -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() diff --git a/app/core/telegram_client.py b/app/core/telegram_client.py new file mode 100644 index 0000000..c799d67 --- /dev/null +++ b/app/core/telegram_client.py @@ -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() diff --git a/app/core/utils.py b/app/core/utils.py new file mode 100644 index 0000000..b5c605d --- /dev/null +++ b/app/core/utils.py @@ -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 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..263cc50 --- /dev/null +++ b/app/main.py @@ -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. " + "

Что бы начать отправлять сообщения, добавьте бота " + "@CismGlobalMonitoring_bot в чат и внесите изменения в группы" + ), + 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 + ) \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..cc87d83 --- /dev/null +++ b/app/models/__init__.py @@ -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", +] diff --git a/app/models/alertmanager.py b/app/models/alertmanager.py new file mode 100644 index 0000000..4b67fd5 --- /dev/null +++ b/app/models/alertmanager.py @@ -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" + } + } + ] + } + } + diff --git a/app/models/grafana.py b/app/models/grafana.py new file mode 100644 index 0000000..7ec710c --- /dev/null +++ b/app/models/grafana.py @@ -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" + } + ] + } + } diff --git a/app/models/group.py b/app/models/group.py new file mode 100644 index 0000000..732d28c --- /dev/null +++ b/app/models/group.py @@ -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 diff --git a/app/models/jira.py b/app/models/jira.py new file mode 100644 index 0000000..242876f --- /dev/null +++ b/app/models/jira.py @@ -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"] + }] + } + } + diff --git a/app/models/message.py b/app/models/message.py new file mode 100644 index 0000000..8bf4944 --- /dev/null +++ b/app/models/message.py @@ -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=["Привет! Это тестовое сообщение.", "Важное уведомление\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": "Критическое уведомление\n\nСистема недоступна!\n\nВремя: 2024-02-08 16:49:44", + "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=["График производительности", "График CPU\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": "График CPU\n\nВремя: 2024-02-08 16:49:44", + "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=["Запись экрана", "Запись работы системы\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": "Запись работы системы\n\nДлительность: 60 сек", + "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=["Аудио уведомление", "Аудио запись\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": "Аудио уведомление\n\nСистема работает нормально", + "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=["Отчет за неделю", "Отчет\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": "Отчет за неделю\n\nДата: 2024-02-08", + "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" + } + ] + } + } + diff --git a/app/models/zabbix.py b/app/models/zabbix.py new file mode 100644 index 0000000..c815de3 --- /dev/null +++ b/app/models/zabbix.py @@ -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" + } + ] + } + } diff --git a/app/modules/__init__.py b/app/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/alertmanager.py b/app/modules/alertmanager.py new file mode 100644 index 0000000..1bebc64 --- /dev/null +++ b/app/modules/alertmanager.py @@ -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"{key}: {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 diff --git a/app/modules/grafana.py b/app/modules/grafana.py new file mode 100644 index 0000000..af8041a --- /dev/null +++ b/app/modules/grafana.py @@ -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"{eval_match.metric}: {eval_match.value}\n" + message_dict['labels'] = labels_text + + # Обрабатываем теги + tags_text = "" + for key, value in alert.tags.items(): + tags_text += f"{key}: {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 diff --git a/app/modules/zabbix.py b/app/modules/zabbix.py new file mode 100644 index 0000000..253bc91 --- /dev/null +++ b/app/modules/zabbix.py @@ -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 diff --git a/config/groups.json.example b/config/groups.json.example new file mode 100644 index 0000000..be544f8 --- /dev/null +++ b/config/groups.json.example @@ -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": {} + } +} + diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..a12ed6a --- /dev/null +++ b/docker-compose.yaml @@ -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 \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..4ad5e40 --- /dev/null +++ b/docs/README.md @@ -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 diff --git a/docs/api/debug.md b/docs/api/debug.md new file mode 100644 index 0000000..d0a3630 --- /dev/null +++ b/docs/api/debug.md @@ -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 +``` + diff --git a/docs/api/decorators.md b/docs/api/decorators.md new file mode 100644 index 0000000..f0f8cff --- /dev/null +++ b/docs/api/decorators.md @@ -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 + diff --git a/docs/api/groups.md b/docs/api/groups.md new file mode 100644 index 0000000..1d53a82 --- /dev/null +++ b/docs/api/groups.md @@ -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) + diff --git a/docs/api/health.md b/docs/api/health.md new file mode 100644 index 0000000..d1c9b13 --- /dev/null +++ b/docs/api/health.md @@ -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 +``` + diff --git a/docs/api/message.md b/docs/api/message.md new file mode 100644 index 0000000..2e8b603 --- /dev/null +++ b/docs/api/message.md @@ -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": "Критическое уведомление\n\nСистема недоступна!\n\nВремя: 2024-02-08 16:49:44", + "parse_mode": "HTML", + "disable_web_page_preview": false +} +``` + +**Пример ответа:** +```json +{ + "status": "ok", + "message": "Сообщение отправлено в чат monitoring, тред 0" +} +``` + +**Поддерживаемые режимы парсинга:** +- `HTML` - HTML форматирование (рекомендуется) +- `Markdown` - Markdown форматирование +- `MarkdownV2` - Markdown V2 форматирование + +**Примеры форматирования:** +- HTML: `жирный`, `курсив`, `код` +- 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": "График CPU\n\nВремя: 2024-02-08 16:49:44", + "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": "Запись работы системы\n\nДлительность: 60 сек", + "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": "Аудио уведомление\n\nСистема работает нормально", + "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": "Отчет за неделю\n\nДата: 2024-02-08", + "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`, по умолчанию используется из конфигурации группы) + diff --git a/docs/bots.md b/docs/bots.md new file mode 100644 index 0000000..9c35f69 --- /dev/null +++ b/docs/bots.md @@ -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 требуется, чтобы бот был добавлен в группу и имел права администратора + diff --git a/docs/groups.md b/docs/groups.md new file mode 100644 index 0000000..95c400a --- /dev/null +++ b/docs/groups.md @@ -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` + diff --git a/docs/jira-mapping.md b/docs/jira-mapping.md new file mode 100644 index 0000000..b810df2 --- /dev/null +++ b/docs/jira-mapping.md @@ -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 + diff --git a/docs/messaging.md b/docs/messaging.md new file mode 100644 index 0000000..173e63e --- /dev/null +++ b/docs/messaging.md @@ -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 + diff --git a/docs/monitoring/alertmanager.md b/docs/monitoring/alertmanager.md new file mode 100644 index 0000000..19b8f79 --- /dev/null +++ b/docs/monitoring/alertmanager.md @@ -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 игнорируется) + diff --git a/docs/monitoring/grafana.md b/docs/monitoring/grafana.md new file mode 100644 index 0000000..3a056c9 --- /dev/null +++ b/docs/monitoring/grafana.md @@ -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 игнорируется) + diff --git a/docs/monitoring/zabbix.md b/docs/monitoring/zabbix.md new file mode 100644 index 0000000..d19b863 --- /dev/null +++ b/docs/monitoring/zabbix.md @@ -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 игнорируется) +- Можно настроить фильтрацию по серьезности события + diff --git a/docs/templates.md b/docs/templates.md new file mode 100644 index 0000000..436c1c1 --- /dev/null +++ b/docs/templates.md @@ -0,0 +1,226 @@ +# Шаблоны сообщений + +## Структура шаблонов + +Шаблоны сообщений находятся в папке `templates/` и используют синтаксис Jinja2. Шаблоны разделены на две категории: + +1. **Шаблоны для мессенджеров** - форматирование сообщений для отправки в Telegram, MAX/VK +2. **Шаблоны для Jira** - форматирование описания тикетов в Jira + +## Шаблоны для мессенджеров + +### Grafana (`templates/grafana.tmpl`) + +Шаблон для форматирования алертов из Grafana: + +```jinja2 +{{ alert_icon }} {{ title }} + +{% if status_name is defined %}{{ status_icon }} Критичность: {{ status_name }}{% endif %} + +Подробнее: +{{ message }} +{% if labels %} +👉 Переменные: + +{{ labels }}{% endif %} +{% if tags and state !='ok' %}💻 Ответственные: + +{{ 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 }} {{ title }} + +{% if status_name is defined %}{{ status_icon }} Критичность: {{ status_name }} ({{ state }}){% endif %} + +Кратко: +{{ subject }} + +Подробнее: +{{ message }} +👉 Значение: +{{ message_data }} + +🌐 Сеть: +Хост: {{ 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 }} {{ alertname }} + +{% if summary is defined %}{{ summary }}{% endif %} + +{% if status_name is defined %}{{ status_icon }} Критичность: {{ status_name }}{% endif %}{% if description is defined %} + +Подробнее: +{{ description }}{% endif %}{% if another_annotations != "" %} +{{ another_annotations }}{% endif %} + +{% if clustername is defined %}👉 Kubernetes: + +Кластер: {{ clustername }}{% if node is defined %} +Нода: {{ node }}{% endif %}{% if ns is defined %} +Неймспейс: {{ ns }}{% endif %}{% if deployment is defined %} +Деплоймент: {{ deployment }}{% endif %}{% if daemonset is defined %} +Демонсет: {{ daemonset }}{% endif %}{% if replicaset is defined %} +Репликасет: {{ replicaset }}{% endif %}{% if statefulset is defined %} +Стейтфулсет: {{ statefulset }}{% endif %}{% if container is defined %} +Контейнер: {{ container }}{% endif %}{% if pod is defined %} +Под: {{ pod }}{% endif %}{% if persistentvolumeclaim is defined %} +PVC: {{ persistentvolumeclaim }}{% endif %}{% if job_name is defined %} +Имя джобы: {{ job_name }}{% endif %}{% if job is defined %} +Джоба: {{ job }}{% endif %}{% if reason is defined %} +Причина: {{ reason }}{% endif %}{% if endpoint is defined %} +Эндпоинт: {{ endpoint }}{% endif %}{% if instance is defined %} +Инстанс: {{ instance }}{% endif %}{% if condition is defined %} +Состояние: {{ condition }}{% endif %}{% if reason is defined %} +Причина: {{ reason }}{% endif %}{% endif %} + +{% if prometheus is defined %}🔍 Прометей: + +Сервер: {{ prometheus }}{% if service is defined %} +Сервис: {{ service }}{% endif %}{% if metrics_path is defined %} +Метрики: {{ metrics_path }}{% endif %}{% if uid is defined %} +UID: {{ uid }}{% endif %}{% if integration is defined %} +Integration: {{ integration }}{% endif %}{% if To is defined %} +To: {{ integration }}{% endif %}{% endif %} + +{% if another_labels != "" %} +🤷 Разное: + +{{ 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` + diff --git a/env.example b/env.example new file mode 100644 index 0000000..ae15890 --- /dev/null +++ b/env.example @@ -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 diff --git a/kubernetes.yaml b/kubernetes.yaml new file mode 100644 index 0000000..86d951e --- /dev/null +++ b/kubernetes.yaml @@ -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 diff --git a/make-wrapper.sh b/make-wrapper.sh new file mode 100755 index 0000000..09cba48 --- /dev/null +++ b/make-wrapper.sh @@ -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 + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b8a7557 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/templates/alertmanager.tmpl b/templates/alertmanager.tmpl new file mode 100644 index 0000000..7694337 --- /dev/null +++ b/templates/alertmanager.tmpl @@ -0,0 +1,43 @@ +{{ alert_icon }} {{ alertname }} + +{% if summary is defined %}{{ summary }}{% endif %} + +{% if status_name is defined %}{{ status_icon }} Критичность: {{ status_name }}{% endif %}{% if description is defined %} + +Подробнее: +{{ description }}{% endif %}{% if another_annotations != "" %} +{{ another_annotations }}{% endif %} + +{% if clustername is defined %}👉 Kubernetes: + +Кластер: {{ clustername }}{% if node is defined %} +Нода: {{ node }}{% endif %}{% if ns is defined %} +Неймспейс: {{ ns }}{% endif %}{% if deployment is defined %} +Деплоймент: {{ deployment }}{% endif %}{% if daemonset is defined %} +Демонсет: {{ daemonset }}{% endif %}{% if replicaset is defined %} +Репликасет: {{ replicaset }}{% endif %}{% if statefulset is defined %} +Стейтфулсет: {{ statefulset }}{% endif %}{% if container is defined %} +Контейнер: {{ container }}{% endif %}{% if pod is defined %} +Под: {{ pod }}{% endif %}{% if persistentvolumeclaim is defined %} +PVC: {{ persistentvolumeclaim }}{% endif %}{% if job_name is defined %} +Имя джобы: {{ job_name }}{% endif %}{% if job is defined %} +Джоба: {{ job }}{% endif %}{% if reason is defined %} +Причина: {{ reason }}{% endif %}{% if endpoint is defined %} +Эндпоинт: {{ endpoint }}{% endif %}{% if instance is defined %} +Инстанс: {{ instance }}{% endif %}{% if condition is defined %} +Состояние: {{ condition }}{% endif %}{% if reason is defined %} +Причина: {{ reason }}{% endif %}{% endif %} + +{% if prometheus is defined %}🔍 Прометей: + +Сервер: {{ prometheus }}{% if service is defined %} +Сервис: {{ service }}{% endif %}{% if metrics_path is defined %} +Метрики: {{ metrics_path }}{% endif %}{% if uid is defined %} +UID: {{ uid }}{% endif %}{% if integration is defined %} +Integration: {{ integration }}{% endif %}{% if To is defined %} +To: {{ integration }}{% endif %}{% endif %} + +{% if another_labels != "" %} +🤷 Разное: + +{{ another_labels }}{% endif %} \ No newline at end of file diff --git a/templates/grafana.tmpl b/templates/grafana.tmpl new file mode 100644 index 0000000..652c111 --- /dev/null +++ b/templates/grafana.tmpl @@ -0,0 +1,13 @@ +{{ alert_icon }} {{ title }} + +{% if status_name is defined %}{{ status_icon }} Критичность: {{ status_name }}{% endif %} + +Подробнее: +{{ message }} +{% if labels %} +👉 Переменные: + +{{ labels }}{% endif %} +{% if tags and state !='ok' %}💻 Ответственные: + +{{ tags }}{% endif %} diff --git a/templates/jira_common.tmpl b/templates/jira_common.tmpl new file mode 100644 index 0000000..e47bdeb --- /dev/null +++ b/templates/jira_common.tmpl @@ -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 %} + +--- + +*Тикет создан автоматически системой мониторинга* + diff --git a/templates/zabbix.tmpl b/templates/zabbix.tmpl new file mode 100644 index 0000000..ddf6d48 --- /dev/null +++ b/templates/zabbix.tmpl @@ -0,0 +1,16 @@ +{{ alert_icon }} {{ title }} + +{% if status_name is defined %}{{ status_icon }} Критичность: {{ status_name }} ({{ state }}){% endif %} + +Кратко: +{{ subject }} + +Подробнее: +{{ message }} +👉 Значение: +{{ message_data }} + +🌐 Сеть: +Хост: {{ label_host }} +IP: {{ label_ip }} +Порт: {{ label_port }} \ No newline at end of file