Initial commit: Message Gateway project
- FastAPI приложение для отправки мониторинговых алертов в мессенджеры - Поддержка Telegram и MAX/VK - Интеграция с Grafana, Zabbix, AlertManager - Автоматическое создание тикетов в Jira - Управление группами мессенджеров через API - Декораторы для авторизации и скрытия эндпоинтов - Подробная документация в папке docs/ Автор: Сергей Антропов Сайт: https://devops.org.ru
This commit is contained in:
87
.gitignore
vendored
Normal file
87
.gitignore
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
|
||||
# Application specific
|
||||
/app/app/dump.json
|
||||
dump.json
|
||||
*.dump
|
||||
|
||||
# Kubernetes
|
||||
*.kubeconfig
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.hypothesis/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# Project specific
|
||||
config/groups.json
|
||||
config/jira_mapping.json
|
||||
|
||||
45
Dockerfile
Normal file
45
Dockerfile
Normal file
@@ -0,0 +1,45 @@
|
||||
# Dockerfile для Telegram Gateway
|
||||
# Автор: Сергей Антропов
|
||||
# Сайт: https://devops.org.ru
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Установка переменных окружения
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# Создание рабочей директории
|
||||
WORKDIR /app
|
||||
|
||||
# Установка системных зависимостей
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Копирование файлов зависимостей
|
||||
COPY requirements.txt .
|
||||
|
||||
# Установка Python зависимостей
|
||||
RUN pip install --upgrade pip && \
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Копирование приложения
|
||||
COPY ./app ./app
|
||||
COPY ./templates ./templates
|
||||
COPY ./config ./config
|
||||
|
||||
# Создание пользователя для запуска приложения
|
||||
RUN useradd -m -u 1000 appuser && \
|
||||
chown -R appuser:appuser /app
|
||||
|
||||
# Переключение на непривилегированного пользователя
|
||||
USER appuser
|
||||
|
||||
# Открытие порта
|
||||
EXPOSE 8000
|
||||
|
||||
# Команда запуска приложения
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
534
Makefile
Normal file
534
Makefile
Normal file
@@ -0,0 +1,534 @@
|
||||
# Makefile для Message Gateway
|
||||
# Автор: Сергей Антропов
|
||||
# Сайт: https://devops.org.ru
|
||||
|
||||
.PHONY: help build up up-build down restart logs shell test clean clean-all lint format install dev stop status health ready version psql redis-cli
|
||||
.PHONY: docker docker-build docker-tag docker-push docker-run docker-stop docker-logs docker-shell
|
||||
.PHONY: env env-check env-create env-create-force
|
||||
.PHONY: git git-status git-pull git-push git-commit git-add git-branch
|
||||
.PHONY: k8s k8s-apply k8s-delete k8s-status k8s-logs k8s-shell k8s-contexts k8s-set-context
|
||||
|
||||
# Цвета для вывода
|
||||
COLOR_RESET := \033[0m
|
||||
COLOR_BOLD := \033[1m
|
||||
COLOR_RED := \033[31m
|
||||
COLOR_GREEN := \033[32m
|
||||
COLOR_YELLOW := \033[33m
|
||||
COLOR_BLUE := \033[34m
|
||||
COLOR_MAGENTA := \033[35m
|
||||
COLOR_CYAN := \033[36m
|
||||
|
||||
# Переменные
|
||||
DOCKER_COMPOSE := docker compose
|
||||
DOCKER := docker
|
||||
PYTHON := python3
|
||||
APP_NAME := message-gateway
|
||||
IMAGE_NAME := message-gateway
|
||||
REGISTRY := hub.cism-ms.ru/library
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "v0.1.0")
|
||||
TAG := $(VERSION)
|
||||
KUBECTL := kubectl
|
||||
K8S_CONTEXT := $(shell $(KUBECTL) config current-context 2>/dev/null || echo "")
|
||||
K8S_NAMESPACE := message-gateway
|
||||
K8S_MANIFEST := kubernetes.yaml
|
||||
GIT := git
|
||||
|
||||
# Проверка наличия .env файла
|
||||
ENV_FILE := .env
|
||||
ifeq ($(wildcard $(ENV_FILE)),)
|
||||
$(warning $(COLOR_YELLOW)Файл .env не найден. Создайте его на основе env.example$(COLOR_RESET))
|
||||
endif
|
||||
|
||||
# ============================================================================
|
||||
# Помощь
|
||||
# ============================================================================
|
||||
|
||||
help: ## 📖 Показать справку по командам
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)Message Gateway - Доступные команды:$(COLOR_RESET)"
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Основные команды:$(COLOR_RESET)"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | grep -v "^[[:space:]]*#" | awk 'BEGIN {FS = ":.*?## "}; {if ($$1 ~ /^(build|up-build|up|down|restart|logs|shell|stop|status|health|ready)$$/) printf " $(COLOR_GREEN)%-25s$(COLOR_RESET) %s\n", $$1, $$2}'
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Разработка:$(COLOR_RESET)"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | grep -v "^[[:space:]]*#" | awk 'BEGIN {FS = ":.*?## "}; {if ($$1 ~ /^(dev|test|lint|format|install|clean|clean-all)$$/) printf " $(COLOR_GREEN)%-25s$(COLOR_RESET) %s\n", $$1, $$2}'
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Docker команды:$(COLOR_RESET)"
|
||||
@echo " $(COLOR_GREEN)make docker build$(COLOR_RESET) 🐳 Собрать Docker образ"
|
||||
@echo " $(COLOR_GREEN)make docker tag$(COLOR_RESET) 🏷️ Тегировать Docker образ"
|
||||
@echo " $(COLOR_GREEN)make docker push$(COLOR_RESET) 📤 Отправить образ в registry"
|
||||
@echo " $(COLOR_GREEN)make docker run$(COLOR_RESET) 🏃 Запустить контейнер"
|
||||
@echo " $(COLOR_GREEN)make docker stop$(COLOR_RESET) 🛑 Остановить контейнер"
|
||||
@echo " $(COLOR_GREEN)make docker logs$(COLOR_RESET) 📋 Показать логи контейнера"
|
||||
@echo " $(COLOR_GREEN)make docker shell$(COLOR_RESET) 🐚 Открыть shell в контейнере"
|
||||
@echo " $(COLOR_YELLOW)Использование: make docker CMD=build$(COLOR_RESET)"
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Git команды:$(COLOR_RESET)"
|
||||
@echo " $(COLOR_GREEN)make git status$(COLOR_RESET) 📊 Показать статус репозитория"
|
||||
@echo " $(COLOR_GREEN)make git pull$(COLOR_RESET) ⬇️ Получить изменения из удаленного репозитория"
|
||||
@echo " $(COLOR_GREEN)make git push$(COLOR_RESET) ⬆️ Отправить изменения в удаленный репозиторий"
|
||||
@echo " $(COLOR_GREEN)make git add [file]$(COLOR_RESET) ➕ Добавить файлы в индекс"
|
||||
@echo " $(COLOR_GREEN)make git commit [msg]$(COLOR_RESET) 💾 Создать коммит"
|
||||
@echo " $(COLOR_GREEN)make git branch$(COLOR_RESET) 🌿 Показать список веток"
|
||||
@echo " $(COLOR_YELLOW)Использование: make git CMD=push ARGS=origin main$(COLOR_RESET)"
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Kubernetes команды:$(COLOR_RESET)"
|
||||
@echo " $(COLOR_GREEN)make k8s contexts$(COLOR_RESET) 📋 Показать доступные контексты"
|
||||
@echo " $(COLOR_GREEN)make k8s set-context [name]$(COLOR_RESET) 🔄 Установить контекст"
|
||||
@echo " $(COLOR_GREEN)make k8s apply$(COLOR_RESET) ✅ Применить манифесты"
|
||||
@echo " $(COLOR_GREEN)make k8s delete$(COLOR_RESET) 🗑️ Удалить ресурсы"
|
||||
@echo " $(COLOR_GREEN)make k8s status$(COLOR_RESET) 📊 Показать статус подов"
|
||||
@echo " $(COLOR_GREEN)make k8s logs$(COLOR_RESET) 📋 Показать логи подов"
|
||||
@echo " $(COLOR_GREEN)make k8s shell$(COLOR_RESET) 🐚 Открыть shell в поде"
|
||||
@echo " $(COLOR_YELLOW)Использование: make k8s CMD=apply CONTEXT=<контекст>$(COLOR_RESET)"
|
||||
@if [ -n "$(K8S_CONTEXT)" ]; then \
|
||||
echo " $(COLOR_CYAN)Текущий контекст: $(COLOR_BOLD)$(K8S_CONTEXT)$(COLOR_RESET)"; \
|
||||
fi
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Env команды:$(COLOR_RESET)"
|
||||
@echo " $(COLOR_GREEN)make env check$(COLOR_RESET) 🔍 Проверить наличие .env файла"
|
||||
@echo " $(COLOR_GREEN)make env create$(COLOR_RESET) 📝 Создать .env файл из env.example"
|
||||
@echo " $(COLOR_GREEN)make env create-force$(COLOR_RESET) 📝 Принудительно создать .env файл"
|
||||
@echo " $(COLOR_YELLOW)Использование: make env CMD=check$(COLOR_RESET)"
|
||||
@echo ""
|
||||
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Утилиты:$(COLOR_RESET)"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | grep -v "^[[:space:]]*#" | awk 'BEGIN {FS = ":.*?## "}; {if ($$1 ~ /^(version|psql|redis-cli)$$/) printf " $(COLOR_GREEN)%-25s$(COLOR_RESET) %s\n", $$1, $$2}'
|
||||
@echo ""
|
||||
|
||||
# ============================================================================
|
||||
# Основные команды
|
||||
# ============================================================================
|
||||
|
||||
build: ## 🔨 Собрать Docker образ
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Сборка Docker образа...$(COLOR_RESET)"
|
||||
@$(DOCKER_COMPOSE) build --no-cache
|
||||
@echo "$(COLOR_GREEN)✓ Образ собран успешно$(COLOR_RESET)"
|
||||
|
||||
up: ## 🚀 Запустить приложение
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Запуск приложения...$(COLOR_RESET)"
|
||||
@$(DOCKER_COMPOSE) up -d
|
||||
@echo "$(COLOR_GREEN)✓ Приложение запущено$(COLOR_RESET)"
|
||||
@echo "$(COLOR_CYAN)Для просмотра логов: $(COLOR_BOLD)make logs$(COLOR_RESET)"
|
||||
|
||||
up-build: build up ## 🔨 Собрать и запустить приложение
|
||||
@echo "$(COLOR_GREEN)✓ Приложение собрано и запущено$(COLOR_RESET)"
|
||||
|
||||
down: ## 🛑 Остановить приложение
|
||||
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Остановка приложения...$(COLOR_RESET)"
|
||||
@$(DOCKER_COMPOSE) down
|
||||
@echo "$(COLOR_GREEN)✓ Приложение остановлено$(COLOR_RESET)"
|
||||
|
||||
restart: ## 🔄 Перезапустить приложение
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Перезапуск приложения...$(COLOR_RESET)"
|
||||
@$(DOCKER_COMPOSE) restart
|
||||
@echo "$(COLOR_GREEN)✓ Приложение перезапущено$(COLOR_RESET)"
|
||||
|
||||
logs: ## 📋 Показать логи приложения
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Просмотр логов приложения...$(COLOR_RESET)"
|
||||
@clear || true
|
||||
@$(DOCKER_COMPOSE) logs -f --tail=50 $(APP_NAME)
|
||||
|
||||
shell: ## 🐚 Открыть shell в контейнере
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Открытие shell в контейнере...$(COLOR_RESET)"
|
||||
@clear || true
|
||||
@$(DOCKER_COMPOSE) exec $(APP_NAME) /bin/bash
|
||||
|
||||
stop: ## ⏹️ Остановить приложение (alias для down)
|
||||
@$(MAKE) down
|
||||
|
||||
status: ## 📊 Показать статус контейнеров
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Статус контейнеров:$(COLOR_RESET)"
|
||||
@$(DOCKER_COMPOSE) ps
|
||||
|
||||
health: ## ❤️ Проверить здоровье приложения
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Проверка здоровья приложения...$(COLOR_RESET)"
|
||||
@curl -f http://localhost:8000/api/v1/health || (echo "$(COLOR_RED)✗ Приложение не отвечает$(COLOR_RESET)" && exit 1)
|
||||
@echo "$(COLOR_GREEN)✓ Приложение здорово$(COLOR_RESET)"
|
||||
|
||||
ready: ## ✅ Проверить готовность приложения
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Проверка готовности приложения...$(COLOR_RESET)"
|
||||
@curl -f http://localhost:8000/api/v1/health/ready || (echo "$(COLOR_RED)✗ Приложение не готово$(COLOR_RESET)" && exit 1)
|
||||
@echo "$(COLOR_GREEN)✓ Приложение готово$(COLOR_RESET)"
|
||||
|
||||
# ============================================================================
|
||||
# Разработка
|
||||
# ============================================================================
|
||||
|
||||
dev: ## 🔧 Запустить в режиме разработки
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Запуск в режиме разработки...$(COLOR_RESET)"
|
||||
@$(DOCKER_COMPOSE) up --build
|
||||
|
||||
test: ## 🧪 Запустить тесты
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Запуск тестов...$(COLOR_RESET)"
|
||||
@if [ -f docker-compose.test.yaml ]; then \
|
||||
$(DOCKER_COMPOSE) -f docker-compose.test.yaml up --abort-on-container-exit; \
|
||||
else \
|
||||
echo "$(COLOR_YELLOW)⚠ Файл docker-compose.test.yaml не найден$(COLOR_RESET)"; \
|
||||
echo "$(COLOR_CYAN)Запуск тестов через pytest...$(COLOR_RESET)"; \
|
||||
$(DOCKER_COMPOSE) exec $(APP_NAME) pytest -v || true; \
|
||||
fi
|
||||
|
||||
lint: ## 🔍 Запустить линтер
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Запуск линтера...$(COLOR_RESET)"
|
||||
@$(DOCKER_COMPOSE) exec $(APP_NAME) python -m pylint app/ || echo "$(COLOR_YELLOW)⚠ Линтер не установлен или найдены ошибки$(COLOR_RESET)"
|
||||
|
||||
format: ## 🎨 Форматировать код
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Форматирование кода...$(COLOR_RESET)"
|
||||
@$(DOCKER_COMPOSE) exec $(APP_NAME) python -m black app/ || echo "$(COLOR_YELLOW)⚠ Black не установлен$(COLOR_RESET)"
|
||||
|
||||
install: ## 📦 Установить зависимости
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Установка зависимостей...$(COLOR_RESET)"
|
||||
@pip install -r requirements.txt || $(PYTHON) -m pip install -r requirements.txt
|
||||
@echo "$(COLOR_GREEN)✓ Зависимости установлены$(COLOR_RESET)"
|
||||
|
||||
clean: ## 🧹 Очистить Docker образы и контейнеры
|
||||
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Очистка Docker ресурсов...$(COLOR_RESET)"
|
||||
@$(DOCKER_COMPOSE) down -v
|
||||
@$(DOCKER) system prune -f
|
||||
@echo "$(COLOR_GREEN)✓ Очистка завершена$(COLOR_RESET)"
|
||||
|
||||
clean-all: ## 🗑️ Очистить все Docker ресурсы (включая volumes)
|
||||
@echo "$(COLOR_BOLD)$(COLOR_RED)Очистка всех Docker ресурсов...$(COLOR_RESET)"
|
||||
@$(DOCKER_COMPOSE) down -v --rmi all
|
||||
@$(DOCKER) system prune -af --volumes
|
||||
@echo "$(COLOR_GREEN)✓ Полная очистка завершена$(COLOR_RESET)"
|
||||
|
||||
# ============================================================================
|
||||
# Docker команды (подкоманды через make docker CMD=<cmd>)
|
||||
# ============================================================================
|
||||
|
||||
docker: ## 🐳 Docker команды (используйте: make docker CMD=build|tag|push|run|stop|logs|shell)
|
||||
@if [ -z "$(CMD)" ]; then \
|
||||
echo "$(COLOR_BOLD)$(COLOR_CYAN)Docker команды:$(COLOR_RESET)"; \
|
||||
echo ""; \
|
||||
echo " $(COLOR_GREEN)make docker build$(COLOR_RESET) - Собрать Docker образ"; \
|
||||
echo " $(COLOR_GREEN)make docker tag$(COLOR_RESET) - Тегировать Docker образ"; \
|
||||
echo " $(COLOR_GREEN)make docker push$(COLOR_RESET) - Отправить образ в registry"; \
|
||||
echo " $(COLOR_GREEN)make docker run$(COLOR_RESET) - Запустить контейнер"; \
|
||||
echo " $(COLOR_GREEN)make docker stop$(COLOR_RESET) - Остановить контейнер"; \
|
||||
echo " $(COLOR_GREEN)make docker logs$(COLOR_RESET) - Показать логи контейнера"; \
|
||||
echo " $(COLOR_GREEN)make docker shell$(COLOR_RESET) - Открыть shell в контейнере"; \
|
||||
echo ""; \
|
||||
echo "$(COLOR_YELLOW)Использование:$(COLOR_RESET)"; \
|
||||
echo " $(COLOR_CYAN)make docker CMD=build$(COLOR_RESET)"; \
|
||||
echo " $(COLOR_CYAN)make docker-build$(COLOR_RESET) (прямой вызов)"; \
|
||||
else \
|
||||
$(MAKE) docker-$(CMD); \
|
||||
fi
|
||||
|
||||
docker-build: ## 🐳 Собрать Docker образ
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Сборка Docker образа...$(COLOR_RESET)"
|
||||
@$(DOCKER) build --no-cache -t $(IMAGE_NAME):$(TAG) .
|
||||
@echo "$(COLOR_GREEN)✓ Образ собран: $(IMAGE_NAME):$(TAG)$(COLOR_RESET)"
|
||||
|
||||
docker-tag: docker-build ## 🏷️ Тегировать Docker образ для registry
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Тегирование образа...$(COLOR_RESET)"
|
||||
@$(DOCKER) tag $(IMAGE_NAME):$(TAG) $(REGISTRY)/$(IMAGE_NAME):$(TAG) || \
|
||||
(echo "$(COLOR_RED)✗ Ошибка тегирования образа$(COLOR_RESET)" && exit 1)
|
||||
@echo "$(COLOR_GREEN)✓ Образ помечен: $(REGISTRY)/$(IMAGE_NAME):$(TAG)$(COLOR_RESET)"
|
||||
|
||||
docker-push: docker-tag ## 📤 Собрать, тегировать и отправить образ в registry
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Отправка образа в registry...$(COLOR_RESET)"
|
||||
@$(DOCKER) push $(REGISTRY)/$(IMAGE_NAME):$(TAG) || \
|
||||
(echo "$(COLOR_RED)✗ Ошибка отправки образа в registry$(COLOR_RESET)" && exit 1)
|
||||
@echo "$(COLOR_GREEN)✓ Образ отправлен: $(REGISTRY)/$(IMAGE_NAME):$(TAG)$(COLOR_RESET)"
|
||||
|
||||
docker-run: docker-build ## 🏃 Запустить контейнер
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Запуск контейнера...$(COLOR_RESET)"
|
||||
@if [ ! -f $(ENV_FILE) ]; then \
|
||||
echo "$(COLOR_RED)✗ Файл .env не найден$(COLOR_RESET)"; \
|
||||
echo "$(COLOR_YELLOW)Создайте файл .env на основе env.example$(COLOR_RESET)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@$(DOCKER) run -d \
|
||||
--name $(APP_NAME) \
|
||||
-p 8000:8000 \
|
||||
--env-file $(ENV_FILE) \
|
||||
-v $$(pwd)/app:/app/app \
|
||||
-v $$(pwd)/config:/app/config \
|
||||
-v $$(pwd)/templates:/app/templates \
|
||||
$(IMAGE_NAME):$(TAG) || \
|
||||
(echo "$(COLOR_RED)✗ Ошибка запуска контейнера$(COLOR_RESET)" && exit 1)
|
||||
@echo "$(COLOR_GREEN)✓ Контейнер запущен$(COLOR_RESET)"
|
||||
|
||||
docker-stop: ## 🛑 Остановить контейнер
|
||||
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Остановка контейнера...$(COLOR_RESET)"
|
||||
@$(DOCKER) stop $(APP_NAME) || echo "$(COLOR_YELLOW)⚠ Контейнер не запущен$(COLOR_RESET)"
|
||||
@$(DOCKER) rm $(APP_NAME) || echo "$(COLOR_YELLOW)⚠ Контейнер не существует$(COLOR_RESET)"
|
||||
@echo "$(COLOR_GREEN)✓ Контейнер остановлен$(COLOR_RESET)"
|
||||
|
||||
docker-logs: ## 📋 Показать логи контейнера
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Просмотр логов контейнера...$(COLOR_RESET)"
|
||||
@clear || true
|
||||
@$(DOCKER) logs -f --tail=50 $(APP_NAME)
|
||||
|
||||
docker-shell: ## 🐚 Открыть shell в контейнере
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Открытие shell в контейнере...$(COLOR_RESET)"
|
||||
@clear || true
|
||||
@$(DOCKER) exec -it $(APP_NAME) /bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Git команды (подкоманды через make git CMD=<cmd>)
|
||||
# ============================================================================
|
||||
|
||||
git: ## 📦 Git команды (используйте: make git CMD=status|pull|push|add|commit|branch)
|
||||
@if [ -z "$(CMD)" ]; then \
|
||||
echo "$(COLOR_BOLD)$(COLOR_CYAN)Git команды:$(COLOR_RESET)"; \
|
||||
echo ""; \
|
||||
echo " $(COLOR_GREEN)make git status$(COLOR_RESET) - Показать статус репозитория"; \
|
||||
echo " $(COLOR_GREEN)make git pull$(COLOR_RESET) - Получить изменения"; \
|
||||
echo " $(COLOR_GREEN)make git push$(COLOR_RESET) - Отправить изменения"; \
|
||||
echo " $(COLOR_GREEN)make git add [file]$(COLOR_RESET) - Добавить файлы"; \
|
||||
echo " $(COLOR_GREEN)make git commit [msg]$(COLOR_RESET) - Создать коммит"; \
|
||||
echo " $(COLOR_GREEN)make git branch$(COLOR_RESET) - Показать список веток"; \
|
||||
echo ""; \
|
||||
echo "$(COLOR_YELLOW)Использование:$(COLOR_RESET)"; \
|
||||
echo " $(COLOR_CYAN)make git CMD=status$(COLOR_RESET)"; \
|
||||
echo " $(COLOR_CYAN)make git CMD=push ARGS=origin main$(COLOR_RESET)"; \
|
||||
echo " $(COLOR_CYAN)make git-status$(COLOR_RESET) (прямой вызов)"; \
|
||||
else \
|
||||
$(MAKE) git-$(CMD) ARGS="$(ARGS)"; \
|
||||
fi
|
||||
|
||||
git-status: ## 📊 Показать статус репозитория
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Статус репозитория:$(COLOR_RESET)"
|
||||
@$(GIT) status
|
||||
|
||||
git-pull: ## ⬇️ Получить изменения из удаленного репозитория
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Получение изменений из удаленного репозитория...$(COLOR_RESET)"
|
||||
@if [ -z "$(ARGS)" ]; then \
|
||||
$(GIT) pull || (echo "$(COLOR_RED)✗ Ошибка получения изменений$(COLOR_RESET)" && exit 1); \
|
||||
else \
|
||||
$(GIT) pull $(ARGS) || (echo "$(COLOR_RED)✗ Ошибка получения изменений$(COLOR_RESET)" && exit 1); \
|
||||
fi
|
||||
@echo "$(COLOR_GREEN)✓ Изменения получены$(COLOR_RESET)"
|
||||
|
||||
git-push: ## ⬆️ Отправить изменения в удаленный репозиторий
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Отправка изменений в удаленный репозиторий...$(COLOR_RESET)"
|
||||
@if [ -z "$(ARGS)" ]; then \
|
||||
$(GIT) push || (echo "$(COLOR_RED)✗ Ошибка отправки изменений$(COLOR_RESET)" && exit 1); \
|
||||
else \
|
||||
$(GIT) push $(ARGS) || (echo "$(COLOR_RED)✗ Ошибка отправки изменений$(COLOR_RESET)" && exit 1); \
|
||||
fi
|
||||
@echo "$(COLOR_GREEN)✓ Изменения отправлены$(COLOR_RESET)"
|
||||
|
||||
git-add: ## ➕ Добавить файлы в индекс
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Добавление файлов в индекс...$(COLOR_RESET)"
|
||||
@if [ -z "$(ARGS)" ]; then \
|
||||
echo "$(COLOR_YELLOW)⚠ Укажите файлы для добавления: make git add ARGS=<файлы>$(COLOR_RESET)"; \
|
||||
echo "$(COLOR_YELLOW)Или используйте: make git-add ARGS=<файлы>$(COLOR_RESET)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@$(GIT) add $(ARGS)
|
||||
@echo "$(COLOR_GREEN)✓ Файлы добавлены в индекс$(COLOR_RESET)"
|
||||
|
||||
git-commit: ## 💾 Создать коммит
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Создание коммита...$(COLOR_RESET)"
|
||||
@if [ -z "$(ARGS)" ]; then \
|
||||
echo "$(COLOR_YELLOW)⚠ Укажите сообщение коммита: make git commit ARGS=\"<сообщение>\"$(COLOR_RESET)"; \
|
||||
echo "$(COLOR_YELLOW)Или используйте: make git-commit ARGS=\"<сообщение>\"$(COLOR_RESET)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@$(GIT) commit -m "$(ARGS)"
|
||||
@echo "$(COLOR_GREEN)✓ Коммит создан$(COLOR_RESET)"
|
||||
|
||||
git-branch: ## 🌿 Показать список веток
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Список веток:$(COLOR_RESET)"
|
||||
@$(GIT) branch -a
|
||||
|
||||
# ============================================================================
|
||||
# Kubernetes команды (подкоманды через make k8s CMD=<cmd>)
|
||||
# ============================================================================
|
||||
|
||||
k8s: ## ☸️ Kubernetes команды (используйте: make k8s CMD=apply|delete|status|logs|shell|contexts|set-context)
|
||||
@if [ -z "$(CMD)" ]; then \
|
||||
echo "$(COLOR_BOLD)$(COLOR_CYAN)Kubernetes команды:$(COLOR_RESET)"; \
|
||||
echo ""; \
|
||||
echo " $(COLOR_GREEN)make k8s contexts$(COLOR_RESET) - Показать доступные контексты"; \
|
||||
echo " $(COLOR_GREEN)make k8s set-context [name]$(COLOR_RESET) - Установить контекст"; \
|
||||
echo " $(COLOR_GREEN)make k8s apply$(COLOR_RESET) - Применить манифесты"; \
|
||||
echo " $(COLOR_GREEN)make k8s delete$(COLOR_RESET) - Удалить ресурсы"; \
|
||||
echo " $(COLOR_GREEN)make k8s status$(COLOR_RESET) - Показать статус подов"; \
|
||||
echo " $(COLOR_GREEN)make k8s logs$(COLOR_RESET) - Показать логи подов"; \
|
||||
echo " $(COLOR_GREEN)make k8s shell$(COLOR_RESET) - Открыть shell в поде"; \
|
||||
echo ""; \
|
||||
if [ -n "$(K8S_CONTEXT)" ]; then \
|
||||
echo "$(COLOR_CYAN)Текущий контекст: $(COLOR_BOLD)$(K8S_CONTEXT)$(COLOR_RESET)"; \
|
||||
else \
|
||||
echo "$(COLOR_YELLOW)⚠ Контекст не установлен$(COLOR_RESET)"; \
|
||||
fi; \
|
||||
echo ""; \
|
||||
echo "$(COLOR_YELLOW)Использование:$(COLOR_RESET)"; \
|
||||
echo " $(COLOR_CYAN)make k8s CMD=apply$(COLOR_RESET)"; \
|
||||
echo " $(COLOR_CYAN)make k8s CMD=apply CONTEXT=<контекст>$(COLOR_RESET)"; \
|
||||
echo " $(COLOR_CYAN)make k8s-apply$(COLOR_RESET) (прямой вызов)"; \
|
||||
else \
|
||||
$(MAKE) k8s-$(CMD) CONTEXT="$(CONTEXT)"; \
|
||||
fi
|
||||
|
||||
k8s-contexts: ## 📋 Показать доступные контексты Kubernetes
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Доступные контексты Kubernetes:$(COLOR_RESET)"
|
||||
@$(KUBECTL) config get-contexts || echo "$(COLOR_YELLOW)⚠ kubectl не установлен или не настроен$(COLOR_RESET)"
|
||||
@if [ -n "$(K8S_CONTEXT)" ]; then \
|
||||
echo ""; \
|
||||
echo "$(COLOR_CYAN)Текущий контекст: $(COLOR_BOLD)$(K8S_CONTEXT)$(COLOR_RESET)"; \
|
||||
fi
|
||||
|
||||
k8s-set-context: ## 🔄 Установить контекст Kubernetes
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Установка контекста Kubernetes...$(COLOR_RESET)"
|
||||
@if [ -z "$(CONTEXT)" ]; then \
|
||||
echo "$(COLOR_YELLOW)⚠ Укажите контекст: make k8s set-context CONTEXT=<имя>$(COLOR_RESET)"; \
|
||||
echo "$(COLOR_YELLOW)Или используйте: make k8s-set-context CONTEXT=<имя>$(COLOR_RESET)"; \
|
||||
echo ""; \
|
||||
echo "$(COLOR_CYAN)Доступные контексты:$(COLOR_RESET)"; \
|
||||
$(KUBECTL) config get-contexts -o name || echo "$(COLOR_YELLOW)⚠ kubectl не установлен$(COLOR_RESET)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@$(KUBECTL) config use-context $(CONTEXT) || \
|
||||
(echo "$(COLOR_RED)✗ Ошибка установки контекста$(COLOR_RESET)" && exit 1)
|
||||
@echo "$(COLOR_GREEN)✓ Контекст установлен: $(CONTEXT)$(COLOR_RESET)"
|
||||
|
||||
k8s-apply: ## ✅ Применить манифесты Kubernetes
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Применение манифестов Kubernetes...$(COLOR_RESET)"
|
||||
@if [ -n "$(CONTEXT)" ]; then \
|
||||
echo "$(COLOR_CYAN)Использование контекста: $(CONTEXT)$(COLOR_RESET)"; \
|
||||
$(KUBECTL) --context=$(CONTEXT) apply -f $(K8S_MANIFEST) || \
|
||||
(echo "$(COLOR_RED)✗ Ошибка применения манифестов$(COLOR_RESET)" && exit 1); \
|
||||
else \
|
||||
if [ -n "$(K8S_CONTEXT)" ]; then \
|
||||
echo "$(COLOR_CYAN)Использование текущего контекста: $(K8S_CONTEXT)$(COLOR_RESET)"; \
|
||||
fi; \
|
||||
$(KUBECTL) apply -f $(K8S_MANIFEST) || \
|
||||
(echo "$(COLOR_RED)✗ Ошибка применения манифестов$(COLOR_RESET)" && exit 1); \
|
||||
fi
|
||||
@echo "$(COLOR_GREEN)✓ Манифесты применены$(COLOR_RESET)"
|
||||
|
||||
k8s-delete: ## 🗑️ Удалить ресурсы Kubernetes
|
||||
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)Удаление ресурсов Kubernetes...$(COLOR_RESET)"
|
||||
@if [ -n "$(CONTEXT)" ]; then \
|
||||
echo "$(COLOR_CYAN)Использование контекста: $(CONTEXT)$(COLOR_RESET)"; \
|
||||
$(KUBECTL) --context=$(CONTEXT) delete -f $(K8S_MANIFEST) || \
|
||||
(echo "$(COLOR_YELLOW)⚠ Ресурсы не найдены или уже удалены$(COLOR_RESET)"); \
|
||||
else \
|
||||
if [ -n "$(K8S_CONTEXT)" ]; then \
|
||||
echo "$(COLOR_CYAN)Использование текущего контекста: $(K8S_CONTEXT)$(COLOR_RESET)"; \
|
||||
fi; \
|
||||
$(KUBECTL) delete -f $(K8S_MANIFEST) || \
|
||||
(echo "$(COLOR_YELLOW)⚠ Ресурсы не найдены или уже удалены$(COLOR_RESET)"); \
|
||||
fi
|
||||
@echo "$(COLOR_GREEN)✓ Ресурсы удалены$(COLOR_RESET)"
|
||||
|
||||
k8s-status: ## 📊 Показать статус подов Kubernetes
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Статус подов Kubernetes:$(COLOR_RESET)"
|
||||
@if [ -n "$(CONTEXT)" ]; then \
|
||||
$(KUBECTL) --context=$(CONTEXT) get pods -n $(K8S_NAMESPACE) || \
|
||||
(echo "$(COLOR_YELLOW)⚠ Поды не найдены$(COLOR_RESET)"); \
|
||||
else \
|
||||
$(KUBECTL) get pods -n $(K8S_NAMESPACE) || \
|
||||
(echo "$(COLOR_YELLOW)⚠ Поды не найдены$(COLOR_RESET)"); \
|
||||
fi
|
||||
|
||||
k8s-logs: ## 📋 Показать логи подов Kubernetes
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Просмотр логов подов Kubernetes...$(COLOR_RESET)"
|
||||
@if [ -n "$(CONTEXT)" ]; then \
|
||||
POD=$$($(KUBECTL) --context=$(CONTEXT) get pods -n $(K8S_NAMESPACE) -l app=$(APP_NAME) -o jsonpath='{.items[0].metadata.name}' 2>/dev/null); \
|
||||
else \
|
||||
POD=$$($(KUBECTL) get pods -n $(K8S_NAMESPACE) -l app=$(APP_NAME) -o jsonpath='{.items[0].metadata.name}' 2>/dev/null); \
|
||||
fi; \
|
||||
if [ -z "$$POD" ]; then \
|
||||
echo "$(COLOR_YELLOW)⚠ Поды не найдены$(COLOR_RESET)"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo "$(COLOR_CYAN)Просмотр логов пода: $$POD$(COLOR_RESET)"; \
|
||||
if [ -n "$(CONTEXT)" ]; then \
|
||||
$(KUBECTL) --context=$(CONTEXT) logs -f -n $(K8S_NAMESPACE) $$POD --tail=50; \
|
||||
else \
|
||||
$(KUBECTL) logs -f -n $(K8S_NAMESPACE) $$POD --tail=50; \
|
||||
fi
|
||||
|
||||
k8s-shell: ## 🐚 Открыть shell в поде Kubernetes
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Открытие shell в поде Kubernetes...$(COLOR_RESET)"
|
||||
@if [ -n "$(CONTEXT)" ]; then \
|
||||
POD=$$($(KUBECTL) --context=$(CONTEXT) get pods -n $(K8S_NAMESPACE) -l app=$(APP_NAME) -o jsonpath='{.items[0].metadata.name}' 2>/dev/null); \
|
||||
else \
|
||||
POD=$$($(KUBECTL) get pods -n $(K8S_NAMESPACE) -l app=$(APP_NAME) -o jsonpath='{.items[0].metadata.name}' 2>/dev/null); \
|
||||
fi; \
|
||||
if [ -z "$$POD" ]; then \
|
||||
echo "$(COLOR_YELLOW)⚠ Поды не найдены$(COLOR_RESET)"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo "$(COLOR_CYAN)Открытие shell в поде: $$POD$(COLOR_RESET)"; \
|
||||
clear || true; \
|
||||
if [ -n "$(CONTEXT)" ]; then \
|
||||
$(KUBECTL) --context=$(CONTEXT) exec -it -n $(K8S_NAMESPACE) $$POD -- /bin/bash || \
|
||||
$(KUBECTL) --context=$(CONTEXT) exec -it -n $(K8S_NAMESPACE) $$POD -- /bin/sh; \
|
||||
else \
|
||||
$(KUBECTL) exec -it -n $(K8S_NAMESPACE) $$POD -- /bin/bash || \
|
||||
$(KUBECTL) exec -it -n $(K8S_NAMESPACE) $$POD -- /bin/sh; \
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Env команды (подкоманды через make env CMD=<cmd>)
|
||||
# ============================================================================
|
||||
|
||||
env: ## 🔐 Env команды (используйте: make env CMD=check|create|create-force)
|
||||
@if [ -z "$(CMD)" ]; then \
|
||||
echo "$(COLOR_BOLD)$(COLOR_CYAN)Env команды:$(COLOR_RESET)"; \
|
||||
echo ""; \
|
||||
echo " $(COLOR_GREEN)make env check$(COLOR_RESET) - Проверить наличие .env файла"; \
|
||||
echo " $(COLOR_GREEN)make env create$(COLOR_RESET) - Создать .env файл из env.example"; \
|
||||
echo " $(COLOR_GREEN)make env create-force$(COLOR_RESET) - Принудительно создать .env файл"; \
|
||||
echo ""; \
|
||||
echo "$(COLOR_YELLOW)Использование:$(COLOR_RESET)"; \
|
||||
echo " $(COLOR_CYAN)make env CMD=check$(COLOR_RESET)"; \
|
||||
echo " $(COLOR_CYAN)make env-check$(COLOR_RESET) (прямой вызов)"; \
|
||||
else \
|
||||
$(MAKE) env-$(CMD); \
|
||||
fi
|
||||
|
||||
env-check: ## 🔍 Проверить наличие .env файла
|
||||
@if [ -f $(ENV_FILE) ]; then \
|
||||
echo "$(COLOR_GREEN)✓ Файл .env найден$(COLOR_RESET)"; \
|
||||
else \
|
||||
echo "$(COLOR_RED)✗ Файл .env не найден$(COLOR_RESET)"; \
|
||||
echo "$(COLOR_YELLOW)Создайте файл .env на основе env.example$(COLOR_RESET)"; \
|
||||
echo "$(COLOR_CYAN)Используйте: make env create$(COLOR_RESET)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
env-create: ## 📝 Создать .env файл из env.example
|
||||
@if [ -f $(ENV_FILE) ]; then \
|
||||
echo "$(COLOR_YELLOW)⚠ Файл .env уже существует$(COLOR_RESET)"; \
|
||||
echo "$(COLOR_YELLOW)⚠ Используйте 'make env create-force' для перезаписи$(COLOR_RESET)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@cp env.example $(ENV_FILE)
|
||||
@echo "$(COLOR_GREEN)✓ Файл .env создан из env.example$(COLOR_RESET)"
|
||||
@echo "$(COLOR_YELLOW)⚠ Не забудьте отредактировать .env и указать необходимые значения$(COLOR_RESET)"
|
||||
|
||||
env-create-force: ## 📝 Принудительно создать .env файл из env.example (перезаписать существующий)
|
||||
@cp env.example $(ENV_FILE)
|
||||
@echo "$(COLOR_GREEN)✓ Файл .env создан из env.example (перезаписан)$(COLOR_RESET)"
|
||||
@echo "$(COLOR_YELLOW)⚠ Не забудьте отредактировать .env и указать необходимые значения$(COLOR_RESET)"
|
||||
|
||||
# ============================================================================
|
||||
# Утилиты
|
||||
# ============================================================================
|
||||
|
||||
version: ## 📌 Показать версию проекта
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)Версия проекта: $(COLOR_RESET)$(COLOR_GREEN)$(VERSION)$(COLOR_RESET)"
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)Тег образа: $(COLOR_RESET)$(COLOR_GREEN)$(TAG)$(COLOR_RESET)"
|
||||
@echo "$(COLOR_BOLD)$(COLOR_CYAN)Регистр: $(COLOR_RESET)$(COLOR_GREEN)$(REGISTRY)$(COLOR_RESET)"
|
||||
|
||||
psql: ## 🗄️ Подключиться к PostgreSQL (если используется)
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Подключение к PostgreSQL...$(COLOR_RESET)"
|
||||
@$(DOCKER_COMPOSE) exec postgres psql -U postgres || echo "$(COLOR_YELLOW)⚠ PostgreSQL не запущен$(COLOR_RESET)"
|
||||
|
||||
redis-cli: ## 🔴 Подключиться к Redis (если используется)
|
||||
@echo "$(COLOR_BOLD)$(COLOR_BLUE)Подключение к Redis...$(COLOR_RESET)"
|
||||
@$(DOCKER_COMPOSE) exec redis redis-cli || echo "$(COLOR_YELLOW)⚠ Redis не запущен$(COLOR_RESET)"
|
||||
|
||||
# ============================================================================
|
||||
# Целевые команды по умолчанию
|
||||
# ============================================================================
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
642
README.md
Normal file
642
README.md
Normal file
@@ -0,0 +1,642 @@
|
||||
# Message Gateway
|
||||
|
||||
Приложение для отправки мониторинговых алертов из Grafana, Zabbix и AlertManager в мессенджеры (Telegram, MAX/VK) и создания тикетов в Jira.
|
||||
|
||||
**Автор:** Сергей Антропов
|
||||
**Сайт:** https://devops.org.ru
|
||||
|
||||
## Описание
|
||||
|
||||
Message Gateway - это микросервис, который принимает webhooks от систем мониторинга (Grafana, Zabbix, AlertManager) и отправляет уведомления в различные мессенджеры (Telegram, MAX/VK). Также поддерживает автоматическое создание тикетов в Jira на основе алертов. Поддерживает фильтрацию по стоп-словам, форматирование сообщений через Jinja2 шаблоны и интеграцию с Prometheus для сбора метрик.
|
||||
|
||||
### Поддержка мессенджеров
|
||||
|
||||
- **Telegram** - Полная поддержка текстовых сообщений, медиа (фото, видео, аудио, документы) и тредов
|
||||
- **MAX/VK** - Поддержка текстовых сообщений и медиа (треды не поддерживаются)
|
||||
|
||||
## Документация
|
||||
|
||||
Подробная документация разделена на отдельные файлы в папке [`docs/`](docs/):
|
||||
|
||||
### Основная документация
|
||||
|
||||
- 📖 [Настройка ботов](docs/bots.md) - как создать ботов в Telegram и MAX/VK
|
||||
- 📖 [Конфигурация групп](docs/groups.md) - как настроить группы мессенджеров
|
||||
- 📖 [Шаблоны сообщений](docs/templates.md) - как работают шаблоны Jinja2
|
||||
- 📖 [Конфигурация Jira Mapping](docs/jira-mapping.md) - как настроить маппинг алертов в Jira
|
||||
- 📖 [Отправка сообщений](docs/messaging.md) - как работает отправка сообщений
|
||||
|
||||
### Настройка систем мониторинга
|
||||
|
||||
- 📖 [Настройка Grafana](docs/monitoring/grafana.md) - как настроить Grafana для отправки алертов
|
||||
- 📖 [Настройка Zabbix](docs/monitoring/zabbix.md) - как настроить Zabbix для отправки алертов
|
||||
- 📖 [Настройка AlertManager](docs/monitoring/alertmanager.md) - как настроить AlertManager для отправки алертов
|
||||
|
||||
### API документация
|
||||
|
||||
- 📖 [API для управления группами](docs/api/groups.md) - управление группами мессенджеров через API
|
||||
- 📖 [API для отправки сообщений](docs/api/message.md) - отправка простых сообщений в мессенджеры
|
||||
- 📖 [API для проверки здоровья](docs/api/health.md) - проверка здоровья и готовности приложения
|
||||
- 📖 [API для отладки](docs/api/debug.md) - сохранение JSON данных для отладки
|
||||
- 📖 [Декораторы API](docs/api/decorators.md) - декораторы для авторизации и скрытия эндпоинтов
|
||||
|
||||
## Возможности
|
||||
|
||||
- ✅ Отправка алертов из Grafana в мессенджеры (Telegram, MAX/VK)
|
||||
- ✅ Отправка алертов из Zabbix в мессенджеры (Telegram, MAX/VK)
|
||||
- ✅ Отправка алертов из AlertManager в мессенджеры (Telegram, MAX/VK)
|
||||
- ✅ Поддержка отправки в треды Telegram (не поддерживается для MAX/VK)
|
||||
- ✅ **Поддержка нескольких мессенджеров (Telegram, MAX/VK)**
|
||||
- ✅ **Отправка простых текстовых сообщений**
|
||||
- ✅ **Отправка фото, видео, аудио и документов**
|
||||
- ✅ **Управление группами мессенджеров (CRUD)**
|
||||
- ✅ **Защита управления группами API ключом**
|
||||
- ✅ **Автоматическое создание тикетов в Jira**
|
||||
- ✅ **Настройка маппинга алертов в Jira тикеты**
|
||||
- ✅ Фильтрация по стоп-словам
|
||||
- ✅ Форматирование сообщений через Jinja2 шаблоны
|
||||
- ✅ Метрики Prometheus
|
||||
- ✅ Интеграция с OpenTelemetry
|
||||
- ✅ Кэширование конфигурации групп
|
||||
- ✅ Асинхронная обработка запросов
|
||||
- ✅ Валидация данных через Pydantic
|
||||
- ✅ Подробное логирование
|
||||
|
||||
## Требования
|
||||
|
||||
- Python 3.11+
|
||||
- Docker и Docker Compose
|
||||
- Telegram Bot Token (для Telegram)
|
||||
- MAX/VK Access Token (для MAX/VK, опционально)
|
||||
- Конфигурация групп мессенджеров (config/groups.json)
|
||||
- Пароль для управления группами (опционально, для CRUD операций)
|
||||
- Jira API Token (опционально, для интеграции с Jira)
|
||||
|
||||
## Установка
|
||||
|
||||
### 1. Клонирование репозитория
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd message-gateway
|
||||
```
|
||||
|
||||
### 2. Настройка переменных окружения
|
||||
|
||||
Создайте файл `.env` на основе `.env.example`:
|
||||
|
||||
```bash
|
||||
cp env.example .env
|
||||
```
|
||||
|
||||
Отредактируйте `.env` и укажите необходимые переменные:
|
||||
|
||||
```env
|
||||
# Telegram настройки
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
|
||||
TELEGRAM_ENABLED=true
|
||||
|
||||
# MAX/VK настройки (опционально)
|
||||
MAX_ACCESS_TOKEN=your_max_access_token_here
|
||||
MAX_API_VERSION=5.131
|
||||
MAX_ENABLED=false
|
||||
|
||||
# Общие настройки мессенджеров
|
||||
DEFAULT_MESSENGER=telegram
|
||||
|
||||
# Настройки мониторинга
|
||||
GRAFANA_URL=http://grafana.example.com
|
||||
ZABBIX_URL=https://zabbix.example.com
|
||||
|
||||
# Jira настройки (опционально)
|
||||
JIRA_ENABLED=false
|
||||
JIRA_URL=https://jira.example.com
|
||||
JIRA_EMAIL=user@example.com
|
||||
JIRA_API_TOKEN=your_jira_api_token_here
|
||||
JIRA_PROJECT_KEY=MON
|
||||
JIRA_DEFAULT_ASSIGNEE=user@example.com
|
||||
JIRA_DEFAULT_ISSUE_TYPE=Bug
|
||||
JIRA_CREATE_ON_ALERT=true
|
||||
JIRA_CREATE_ON_RESOLVED=false
|
||||
```
|
||||
|
||||
### 3. Настройка конфигурации групп
|
||||
|
||||
Отредактируйте `config/groups.json` и добавьте группы мессенджеров.
|
||||
|
||||
> 📖 **Подробная документация:** [Конфигурация групп](docs/groups.md)
|
||||
|
||||
```json
|
||||
{
|
||||
"monitoring": {
|
||||
"messenger": "telegram",
|
||||
"chat_id": -1001234567890,
|
||||
"thread_id": 0,
|
||||
"config": {}
|
||||
},
|
||||
"alerts": {
|
||||
"messenger": "telegram",
|
||||
"chat_id": -1001234567891,
|
||||
"thread_id": 0,
|
||||
"config": {}
|
||||
},
|
||||
"max_alerts": {
|
||||
"messenger": "max",
|
||||
"chat_id": "123456789",
|
||||
"thread_id": null,
|
||||
"config": {
|
||||
"access_token": "optional_override_max_token"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Примечания:**
|
||||
- `messenger` - тип мессенджера (`telegram`, `max`) - обязательное поле
|
||||
- `chat_id` - ID чата (может быть числом для Telegram или строкой для MAX/VK) - обязательное поле
|
||||
- `thread_id` - ID треда в группе (только для Telegram, `0` для основной группы, `null` для MAX/VK)
|
||||
- `config` - дополнительная конфигурация для мессенджера (например, `access_token` для MAX/VK)
|
||||
|
||||
### 4. Настройка конфигурации маппинга Jira
|
||||
|
||||
Отредактируйте `config/jira_mapping.json` и настройте маппинг алертов в Jira тикеты.
|
||||
|
||||
> 📖 **Подробная документация:** [Конфигурация Jira Mapping](docs/jira-mapping.md)
|
||||
|
||||
```json
|
||||
{
|
||||
"alertmanager": {
|
||||
"default_project": "MON",
|
||||
"default_assignee": null,
|
||||
"default_issue_type": "Bug",
|
||||
"default_priority": "High",
|
||||
"mappings": [
|
||||
{
|
||||
"conditions": {
|
||||
"severity": "critical",
|
||||
"namespace": "production"
|
||||
},
|
||||
"project": "MON",
|
||||
"assignee": null,
|
||||
"issue_type": "Bug",
|
||||
"priority": "Highest",
|
||||
"labels": ["critical", "production", "alertmanager"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Запуск приложения
|
||||
|
||||
#### Использование Docker Compose
|
||||
|
||||
```bash
|
||||
# Первый запуск (сборка и запуск)
|
||||
make up-build
|
||||
|
||||
# Последующие запуски
|
||||
make up
|
||||
|
||||
# Или напрямую через docker-compose
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### Использование Makefile
|
||||
|
||||
```bash
|
||||
make help # Показать справку по всем командам
|
||||
make build # Собрать Docker образ
|
||||
make up # Запустить приложение
|
||||
make up-build # Собрать и запустить приложение (рекомендуется для первого запуска)
|
||||
make logs # Показать логи
|
||||
make shell # Открыть shell в контейнере
|
||||
make down # Остановить приложение
|
||||
make status # Показать статус контейнеров
|
||||
make health # Проверить здоровье приложения
|
||||
make ready # Проверить готовность приложения
|
||||
```
|
||||
|
||||
> **Примечание:** Все команды выполняются через Makefile. Скрипты `.sh` больше не используются и были удалены.
|
||||
>
|
||||
> **Подкоманды:** Для удобства использования подкоманд (docker, git, k8s, env) доступны три варианта:
|
||||
> 1. Через переменную CMD: `make docker CMD=build`
|
||||
> 2. Напрямую: `make docker-build`
|
||||
> 3. Через обертку: `./make-wrapper.sh docker build` (для синтаксиса с пробелами)
|
||||
|
||||
Доступные команды:
|
||||
|
||||
**Основные команды:**
|
||||
- `make build` - Собрать Docker образ
|
||||
- `make up` - Запустить приложение
|
||||
- `make up-build` - Собрать и запустить приложение (рекомендуется для первого запуска)
|
||||
- `make down` - Остановить приложение
|
||||
- `make restart` - Перезапустить приложение
|
||||
- `make logs` - Показать логи приложения
|
||||
- `make shell` - Открыть shell в контейнере
|
||||
- `make stop` - Остановить приложение (alias для down)
|
||||
- `make status` - Показать статус контейнеров
|
||||
- `make health` - Проверить здоровье приложения
|
||||
- `make ready` - Проверить готовность приложения
|
||||
|
||||
**Разработка:**
|
||||
- `make dev` - Запустить в режиме разработки
|
||||
- `make test` - Запустить тесты
|
||||
- `make lint` - Запустить линтер
|
||||
- `make format` - Форматировать код
|
||||
- `make install` - Установить зависимости
|
||||
- `make clean` - Очистить Docker образы и контейнеры
|
||||
- `make clean-all` - Очистить все Docker ресурсы (включая volumes)
|
||||
|
||||
**Docker команды:**
|
||||
- `make docker CMD=build` или `make docker-build` - Собрать Docker образ (без docker-compose)
|
||||
- `make docker CMD=tag` или `make docker-tag` - Тегировать Docker образ для registry
|
||||
- `make docker CMD=push` или `make docker-push` - Собрать, тегировать и отправить образ в registry
|
||||
- `make docker CMD=run` или `make docker-run` - Запустить контейнер (без docker-compose)
|
||||
- `make docker CMD=stop` или `make docker-stop` - Остановить контейнер (без docker-compose)
|
||||
- `make docker CMD=logs` или `make docker-logs` - Показать логи контейнера (без docker-compose)
|
||||
- `make docker CMD=shell` или `make docker-shell` - Открыть shell в контейнере (без docker-compose)
|
||||
- `./make-wrapper.sh docker build` - Альтернативный синтаксис через обертку
|
||||
|
||||
**Git команды:**
|
||||
- `make git CMD=status` или `make git-status` - Показать статус репозитория
|
||||
- `make git CMD=pull` или `make git-pull` - Получить изменения из удаленного репозитория
|
||||
- `make git CMD=push ARGS=origin main` или `make git-push` - Отправить изменения в удаленный репозиторий
|
||||
- `make git CMD=add ARGS=file1 file2` или `make git-add ARGS=file1 file2` - Добавить файлы в индекс
|
||||
- `make git CMD=commit ARGS="message"` или `make git-commit ARGS="message"` - Создать коммит
|
||||
- `make git CMD=branch` или `make git-branch` - Показать список веток
|
||||
- `./make-wrapper.sh git status` - Альтернативный синтаксис через обертку
|
||||
- `./make-wrapper.sh git push origin main` - Отправить изменения с аргументами
|
||||
|
||||
**Kubernetes команды:**
|
||||
- `make k8s CMD=contexts` или `make k8s-contexts` - Показать доступные контексты
|
||||
- `make k8s CMD=set-context CONTEXT=prod` или `make k8s-set-context CONTEXT=prod` - Установить контекст
|
||||
- `make k8s CMD=apply` или `make k8s-apply` - Применить манифесты
|
||||
- `make k8s CMD=apply CONTEXT=prod` - Применить манифесты с указанием контекста
|
||||
- `make k8s CMD=delete` или `make k8s-delete` - Удалить ресурсы
|
||||
- `make k8s CMD=status` или `make k8s-status` - Показать статус подов
|
||||
- `make k8s CMD=logs` или `make k8s-logs` - Показать логи подов
|
||||
- `make k8s CMD=shell` или `make k8s-shell` - Открыть shell в поде
|
||||
- `./make-wrapper.sh k8s apply CONTEXT=prod` - Альтернативный синтаксис через обертку
|
||||
|
||||
**Env команды:**
|
||||
- `make env CMD=check` или `make env-check` - Проверить наличие .env файла
|
||||
- `make env CMD=create` или `make env-create` - Создать .env файл из env.example
|
||||
- `make env CMD=create-force` или `make env-create-force` - Принудительно создать .env файл (перезаписать существующий)
|
||||
- `./make-wrapper.sh env check` - Альтернативный синтаксис через обертку
|
||||
|
||||
**Утилиты:**
|
||||
- `make version` - Показать версию проекта
|
||||
- `make psql` - Подключиться к PostgreSQL (если используется)
|
||||
- `make redis-cli` - Подключиться к Redis (если используется)
|
||||
|
||||
## Использование
|
||||
|
||||
### API Endpoints
|
||||
|
||||
> 📖 **Подробная документация API:**
|
||||
> - [API для управления группами](docs/api/groups.md)
|
||||
> - [API для отправки сообщений](docs/api/message.md)
|
||||
> - [API для проверки здоровья](docs/api/health.md)
|
||||
> - [API для отладки](docs/api/debug.md)
|
||||
|
||||
#### Мониторинг (без авторизации)
|
||||
|
||||
Все эндпоинты мониторинга объединены под тегом `monitoring` и **не требуют авторизации**:
|
||||
|
||||
- `POST /api/v1/grafana/{group_name}/{thread_id}` - Отправка алерта из Grafana
|
||||
- `POST /api/v1/zabbix/{group_name}/{thread_id}` - Отправка алерта из Zabbix
|
||||
- `POST /api/v1/alertmanager/{k8s_cluster}/{group_name}/{thread_id}` - Отправка алерта из AlertManager
|
||||
|
||||
> 📖 **Подробная документация:**
|
||||
> - [Настройка Grafana](docs/monitoring/grafana.md)
|
||||
> - [Настройка Zabbix](docs/monitoring/zabbix.md)
|
||||
> - [Настройка AlertManager](docs/monitoring/alertmanager.md)
|
||||
|
||||
**Примечание:** Эндпоинты мониторинга объединены в один файл `app/api/v1/endpoints/monitoring.py` и используют общий тег `monitoring` в Swagger UI.
|
||||
|
||||
#### Управление группами (требуется API ключ)
|
||||
|
||||
- `GET /api/v1/groups/messengers` - Получить список поддерживаемых мессенджеров
|
||||
- `GET /api/v1/groups` - Получить список групп (без API ключа - только названия, с API ключом - полная информация)
|
||||
- `POST /api/v1/groups` - Создать группу
|
||||
- `PUT /api/v1/groups/{group_name}` - Обновить группу
|
||||
- `DELETE /api/v1/groups/{group_name}` - Удалить группу
|
||||
|
||||
> 📖 **Подробная документация:** [API для управления группами](docs/api/groups.md)
|
||||
|
||||
#### Отправка сообщений (требуется API ключ)
|
||||
|
||||
Все эндпоинты для отправки сообщений защищены декоратором `@require_api_key` и **требуют API ключ** в заголовке `X-API-Key`:
|
||||
|
||||
- `POST /api/v1/message/text` - Отправить текстовое сообщение
|
||||
- `POST /api/v1/message/photo` - Отправить фото
|
||||
- `POST /api/v1/message/video` - Отправить видео
|
||||
- `POST /api/v1/message/audio` - Отправить аудио
|
||||
- `POST /api/v1/message/document` - Отправить документ
|
||||
|
||||
**Аутентификация:**
|
||||
- Все эндпоинты требуют заголовок `X-API-Key: your_api_key_here`
|
||||
- API ключ настраивается в переменной окружения `API_KEY` в файле `.env`
|
||||
- В Swagger UI доступна авторизация через кнопку "Authorize" (🔒)
|
||||
|
||||
> 📖 **Подробная документация:** [API для отправки сообщений](docs/api/message.md)
|
||||
|
||||
#### Проверка здоровья (без авторизации)
|
||||
|
||||
- `GET /api/v1/health` - Проверка здоровья и готовности приложения
|
||||
|
||||
> 📖 **Подробная документация:** [API для проверки здоровья](docs/api/health.md)
|
||||
|
||||
#### Отладка
|
||||
|
||||
- `POST /api/v1/debug/dump` - Сохранить JSON данные для отладки
|
||||
|
||||
> 📖 **Подробная документация:** [API для отладки](docs/api/debug.md)
|
||||
|
||||
#### Jira
|
||||
|
||||
**Примечание:** Создание тикетов в Jira происходит автоматически при получении алертов (внутренний процесс). API endpoints для создания тикетов вручную удалены в соответствии с требованиями.
|
||||
|
||||
> 📖 **Подробная документация:** [Конфигурация Jira Mapping](docs/jira-mapping.md)
|
||||
|
||||
### Настройка Webhooks
|
||||
|
||||
> 📖 **Подробная документация:**
|
||||
> - [Настройка Grafana](docs/monitoring/grafana.md)
|
||||
> - [Настройка Zabbix](docs/monitoring/zabbix.md)
|
||||
> - [Настройка AlertManager](docs/monitoring/alertmanager.md)
|
||||
|
||||
## Интеграция с Jira
|
||||
|
||||
### Настройка Jira
|
||||
|
||||
> 📖 **Подробная документация:** [Конфигурация Jira Mapping](docs/jira-mapping.md)
|
||||
|
||||
**Примечание:** Создание тикетов в Jira происходит автоматически при получении алертов (внутренний процесс). API endpoints для создания тикетов вручную удалены в соответствии с требованиями.
|
||||
|
||||
## Стоп-слова
|
||||
|
||||
Приложение поддерживает фильтрацию алертов по стоп-словам. Алерты, содержащие следующие паттерны, не будут отправлены:
|
||||
|
||||
- `InfoInhibitor`
|
||||
- `Watchdog`
|
||||
- `etcdHighCommitDurations`
|
||||
- `etcdHighNumberOfFailedGRPCRequests`
|
||||
- `kubePersistentVolumeFillingUp`
|
||||
- `kubePersistentVolumeInodesFillingUp`
|
||||
|
||||
## Метрики Prometheus
|
||||
|
||||
Приложение отправляет метрики в Prometheus Pushgateway:
|
||||
|
||||
- `tg_monitoring_gateway_api_endpoint_total` - общее количество обращений к эндпоинтам API
|
||||
- `tg_monitoring_gateway_total_messages` - всего сообщений получено
|
||||
- `tg_monitoring_gateway_sent_messages` - сообщений успешно отправлено
|
||||
- `tg_monitoring_gateway_reject_messages` - сообщений отклонено (стоп-слова)
|
||||
- `tg_monitoring_gateway_error_messages` - ошибок отправки сообщений
|
||||
- `tg_monitoring_gateway_firing_messages` - горящих алертов
|
||||
- `tg_monitoring_gateway_critical_messages` - критических алертов
|
||||
- `tg_monitoring_gateway_resolved_messages` - исправленных алертов
|
||||
- `tg_monitoring_gateway_jira_tickets_created` - Jira тикетов создано
|
||||
- `tg_monitoring_gateway_jira_tickets_errors` - ошибок создания Jira тикетов
|
||||
|
||||
## Отправка простых сообщений
|
||||
|
||||
> 📖 **Подробная документация:** [API для отправки сообщений](docs/api/message.md)
|
||||
|
||||
Приложение поддерживает отправку простых сообщений (текст, фото, видео, аудио, документы) в мессенджеры (Telegram, MAX/VK) без привязки к системам мониторинга.
|
||||
|
||||
## Разработка
|
||||
|
||||
### Запуск в режиме разработки
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
### Запуск тестов
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
### Линтинг
|
||||
|
||||
```bash
|
||||
make lint
|
||||
```
|
||||
|
||||
### Форматирование кода
|
||||
|
||||
```bash
|
||||
make format
|
||||
```
|
||||
|
||||
### Просмотр всех доступных команд
|
||||
|
||||
```bash
|
||||
make help
|
||||
```
|
||||
|
||||
### Проверка здоровья приложения
|
||||
|
||||
```bash
|
||||
make health # Проверить здоровье
|
||||
make ready # Проверить готовность
|
||||
```
|
||||
|
||||
### Управление Docker образами
|
||||
|
||||
```bash
|
||||
# Использование через переменную CMD
|
||||
make docker CMD=build # Собрать образ
|
||||
make docker CMD=tag # Тегировать образ
|
||||
make docker CMD=push # Отправить образ в registry
|
||||
|
||||
# Или напрямую
|
||||
make docker-build # Собрать образ
|
||||
make docker-tag # Тегировать образ
|
||||
make docker-push # Отправить образ в registry
|
||||
|
||||
# Или через обертку
|
||||
./make-wrapper.sh docker build
|
||||
./make-wrapper.sh docker push
|
||||
```
|
||||
|
||||
### Работа с Git
|
||||
|
||||
```bash
|
||||
# Использование через переменную CMD
|
||||
make git CMD=status # Показать статус
|
||||
make git CMD=pull # Получить изменения
|
||||
make git CMD=push ARGS=origin main # Отправить изменения
|
||||
make git CMD=add ARGS=file1 file2 # Добавить файлы
|
||||
make git CMD=commit ARGS="message" # Создать коммит
|
||||
|
||||
# Или напрямую
|
||||
make git-status
|
||||
make git-pull
|
||||
make git-push
|
||||
|
||||
# Или через обертку
|
||||
./make-wrapper.sh git status
|
||||
./make-wrapper.sh git push origin main
|
||||
./make-wrapper.sh git commit "Update README"
|
||||
```
|
||||
|
||||
### Работа с Kubernetes
|
||||
|
||||
```bash
|
||||
# Показать доступные контексты
|
||||
make k8s CMD=contexts
|
||||
# или
|
||||
make k8s-contexts
|
||||
# или
|
||||
./make-wrapper.sh k8s contexts
|
||||
|
||||
# Установить контекст
|
||||
make k8s CMD=set-context CONTEXT=prod
|
||||
# или
|
||||
make k8s-set-context CONTEXT=prod
|
||||
# или
|
||||
./make-wrapper.sh k8s set-context CONTEXT=prod
|
||||
|
||||
# Применить манифесты
|
||||
make k8s CMD=apply
|
||||
# или с указанием контекста
|
||||
make k8s CMD=apply CONTEXT=prod
|
||||
# или
|
||||
make k8s-apply
|
||||
# или
|
||||
./make-wrapper.sh k8s apply CONTEXT=prod
|
||||
|
||||
# Показать статус подов
|
||||
make k8s CMD=status
|
||||
# или
|
||||
make k8s-status
|
||||
|
||||
# Показать логи подов
|
||||
make k8s CMD=logs
|
||||
# или
|
||||
make k8s-logs
|
||||
|
||||
# Открыть shell в поде
|
||||
make k8s CMD=shell
|
||||
# или
|
||||
make k8s-shell
|
||||
```
|
||||
|
||||
### Работа с .env файлом
|
||||
|
||||
```bash
|
||||
# Проверить наличие .env файла
|
||||
make env CMD=check
|
||||
# или
|
||||
make env-check
|
||||
# или
|
||||
./make-wrapper.sh env check
|
||||
|
||||
# Создать .env файл из env.example
|
||||
make env CMD=create
|
||||
# или
|
||||
make env-create
|
||||
# или
|
||||
./make-wrapper.sh env create
|
||||
|
||||
# Принудительно создать .env файл
|
||||
make env CMD=create-force
|
||||
# или
|
||||
make env-create-force
|
||||
# или
|
||||
./make-wrapper.sh env create-force
|
||||
```
|
||||
|
||||
## Развертывание в Kubernetes
|
||||
|
||||
1. Создайте Secret с переменными окружения:
|
||||
|
||||
```bash
|
||||
kubectl create secret generic message-gateway-secret \
|
||||
--from-literal=telegram_bot_token=your_token \
|
||||
--from-literal=grafana_url=http://grafana.example.com \
|
||||
--from-literal=jira_enabled=true \
|
||||
--from-literal=jira_url=https://jira.example.com \
|
||||
--from-literal=jira_email=user@example.com \
|
||||
--from-literal=jira_api_token=your_jira_api_token \
|
||||
-n message-gateway
|
||||
```
|
||||
|
||||
2. Примените манифесты:
|
||||
|
||||
```bash
|
||||
# Использование make
|
||||
make k8s CMD=apply
|
||||
# или с указанием контекста
|
||||
make k8s CMD=apply CONTEXT=prod
|
||||
# или напрямую
|
||||
make k8s-apply
|
||||
# или через обертку
|
||||
./make-wrapper.sh k8s apply CONTEXT=prod
|
||||
|
||||
# Или напрямую через kubectl
|
||||
kubectl apply -f kubernetes.yaml
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
message-gateway/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ └── v1/
|
||||
│ │ ├── endpoints/ # Эндпоинты API
|
||||
│ │ │ ├── jira.py # Эндпоинты Jira
|
||||
│ │ │ ├── message.py # Эндпоинты для отправки сообщений
|
||||
│ │ │ ├── groups.py # Эндпоинты для управления группами
|
||||
│ │ │ └── ...
|
||||
│ │ └── router.py # Роутер API
|
||||
│ ├── core/ # Основные утилиты
|
||||
│ │ ├── config.py # Конфигурация
|
||||
│ │ ├── telegram_client.py # Клиент Telegram
|
||||
│ │ ├── jira_client.py # Клиент Jira
|
||||
│ │ ├── jira_mapping.py # Управление маппингом Jira
|
||||
│ │ ├── jira_utils.py # Утилиты для работы с Jira
|
||||
│ │ ├── groups.py # Управление группами
|
||||
│ │ ├── metrics.py # Метрики Prometheus
|
||||
│ │ └── utils.py # Вспомогательные утилиты
|
||||
│ ├── models/ # Модели Pydantic
|
||||
│ │ ├── jira.py # Модели Jira
|
||||
│ │ ├── message.py # Модели для отправки сообщений
|
||||
│ │ ├── group.py # Модели для управления группами
|
||||
│ │ ├── grafana.py # Модели Grafana
|
||||
│ │ ├── zabbix.py # Модели Zabbix
|
||||
│ │ ├── alertmanager.py # Модели AlertManager
|
||||
│ │ └── ...
|
||||
│ ├── modules/ # Модули обработки алертов
|
||||
│ └── main.py # Точка входа приложения
|
||||
├── config/ # Конфигурационные файлы
|
||||
│ ├── groups.json # Конфигурация групп Telegram
|
||||
│ └── jira_mapping.json # Конфигурация маппинга Jira
|
||||
├── templates/ # Jinja2 шаблоны
|
||||
│ ├── jira_common.tmpl # Шаблон описания Jira тикета
|
||||
│ └── ...
|
||||
├── docker-compose.yaml # Docker Compose конфигурация
|
||||
├── Dockerfile # Docker образ
|
||||
├── Makefile # Makefile для управления проектом
|
||||
├── make-wrapper.sh # Обертка для поддержки подкоманд с пробелами
|
||||
├── kubernetes.yaml # Kubernetes манифесты
|
||||
├── requirements.txt # Python зависимости
|
||||
├── env.example # Пример файла окружения
|
||||
└── README.md # Документация
|
||||
```
|
||||
|
||||
## Лицензия
|
||||
|
||||
Apache 2.0
|
||||
|
||||
## Контакты
|
||||
|
||||
- **Автор:** Сергей Антропов
|
||||
- **Email:** sergey@antropoff.ru
|
||||
- **Сайт:** https://devops.org.ru
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
6
app/api/__init__.py
Normal file
6
app/api/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
API модули приложения.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
6
app/api/v1/__init__.py
Normal file
6
app/api/v1/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
API версии 1.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
23
app/api/v1/endpoints/__init__.py
Normal file
23
app/api/v1/endpoints/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Эндпоинты API версии 1.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
from app.api.v1.endpoints import (
|
||||
health,
|
||||
monitoring,
|
||||
debug,
|
||||
jira,
|
||||
message,
|
||||
groups,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"health",
|
||||
"monitoring",
|
||||
"debug",
|
||||
"jira",
|
||||
"message",
|
||||
"groups",
|
||||
]
|
||||
114
app/api/v1/endpoints/debug.py
Normal file
114
app/api/v1/endpoints/debug.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Эндпоинты для отладки.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, HTTPException, Body
|
||||
import aiofiles
|
||||
|
||||
from app.core.metrics import metrics
|
||||
from app.core.auth import hide_from_api
|
||||
# Импортируем settings в функции, чтобы избежать циклических зависимостей
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/debug", tags=["debug"])
|
||||
|
||||
|
||||
@hide_from_api
|
||||
@router.post(
|
||||
"/dump",
|
||||
name="JSON Debug dump",
|
||||
response_model=Dict[str, Any],
|
||||
include_in_schema=False, # Скрываем эндпоинт из Swagger UI
|
||||
responses={
|
||||
200: {
|
||||
"description": "Данные успешно сохранены",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"status": "ok",
|
||||
"message": "Данные сохранены в dump.json",
|
||||
"data": {
|
||||
"test": "data",
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"source": "grafana",
|
||||
"alert": {
|
||||
"title": "Test alert",
|
||||
"state": "alerting"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
500: {
|
||||
"description": "Ошибка сохранения данных",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Ошибка записи в файл"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
async def dump_request(
|
||||
dump: Dict[str, Any] = Body(
|
||||
...,
|
||||
description="JSON данные для сохранения в файл dump.json",
|
||||
examples=[
|
||||
{
|
||||
"test": "data",
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"source": "grafana",
|
||||
"alert": {
|
||||
"title": "Test alert",
|
||||
"state": "alerting"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "zabbix",
|
||||
"event": {
|
||||
"event-id": "8819711",
|
||||
"event-name": "High CPU utilization",
|
||||
"status": "PROBLEM"
|
||||
}
|
||||
},
|
||||
{
|
||||
"source": "alertmanager",
|
||||
"status": "firing",
|
||||
"commonLabels": {
|
||||
"alertname": "HighCPUUsage",
|
||||
"severity": "critical"
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Сохранить JSON данные в файл для отладки.
|
||||
|
||||
Используется для сохранения входящих webhook запросов для анализа.
|
||||
|
||||
Подробная документация: см. docs/api/debug.md
|
||||
"""
|
||||
metrics.increment_api_endpoint("debug_dump")
|
||||
logger.info("Получен запрос на сохранение данных для отладки")
|
||||
|
||||
try:
|
||||
dump_path = "/app/app/dump.json"
|
||||
async with aiofiles.open(dump_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(json.dumps(dump, indent=4, ensure_ascii=False))
|
||||
logger.info(f"Данные сохранены в {dump_path}")
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Данные сохранены в dump.json",
|
||||
"data": dump
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сохранения данных: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка записи в файл: {str(e)}")
|
||||
511
app/api/v1/endpoints/groups.py
Normal file
511
app/api/v1/endpoints/groups.py
Normal file
@@ -0,0 +1,511 @@
|
||||
"""
|
||||
Эндпоинты для управления группами мессенджеров.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from fastapi import APIRouter, HTTPException, Query, Path, Body, Request
|
||||
|
||||
from app.core.metrics import metrics
|
||||
from app.core.groups import groups_config
|
||||
from app.core.auth import require_api_key, require_api_key_dependency, require_api_key_optional
|
||||
from app.models.group import (
|
||||
CreateGroupRequest,
|
||||
UpdateGroupRequest,
|
||||
DeleteGroupRequest,
|
||||
GroupInfo
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/groups", tags=["groups"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/messengers",
|
||||
name="Получить список поддерживаемых мессенджеров",
|
||||
response_model=Dict[str, Any],
|
||||
responses={
|
||||
200: {
|
||||
"description": "Список поддерживаемых мессенджеров",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"status": "ok",
|
||||
"messengers": [
|
||||
{
|
||||
"type": "telegram",
|
||||
"name": "Telegram",
|
||||
"supports_threads": True,
|
||||
"enabled": True
|
||||
},
|
||||
{
|
||||
"type": "max",
|
||||
"name": "MAX/VK",
|
||||
"supports_threads": False,
|
||||
"enabled": False
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
async def get_messengers() -> Dict[str, Any]:
|
||||
"""
|
||||
Получить список поддерживаемых мессенджеров.
|
||||
|
||||
Returns:
|
||||
Список поддерживаемых мессенджеров с их характеристиками.
|
||||
|
||||
Подробная документация: см. docs/api/groups.md
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
messengers = [
|
||||
{
|
||||
"type": "telegram",
|
||||
"name": "Telegram",
|
||||
"supports_threads": True,
|
||||
"enabled": settings.telegram_enabled
|
||||
},
|
||||
{
|
||||
"type": "max",
|
||||
"name": "MAX/VK",
|
||||
"supports_threads": False,
|
||||
"enabled": settings.max_enabled
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"messengers": messengers
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
name="Получить список групп",
|
||||
response_model=Dict[str, Any],
|
||||
responses={
|
||||
200: {
|
||||
"description": "Список групп успешно получен",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"without_api_key": {
|
||||
"summary": "Без API ключа (только названия)",
|
||||
"value": {
|
||||
"status": "ok",
|
||||
"groups": [
|
||||
{"name": "monitoring", "chat_id": None},
|
||||
{"name": "alerts", "chat_id": None}
|
||||
],
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"with_api_key": {
|
||||
"summary": "С API ключом (полная информация)",
|
||||
"value": {
|
||||
"status": "ok",
|
||||
"groups": [
|
||||
{
|
||||
"name": "monitoring",
|
||||
"messenger": "telegram",
|
||||
"chat_id": -1001234567890,
|
||||
"thread_id": 0
|
||||
},
|
||||
{
|
||||
"name": "alerts_max",
|
||||
"messenger": "max",
|
||||
"chat_id": "123456789",
|
||||
"thread_id": None
|
||||
}
|
||||
],
|
||||
"count": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
401: {
|
||||
"description": "Неверный API ключ",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Неверный или отсутствующий API ключ"}
|
||||
}
|
||||
}
|
||||
},
|
||||
500: {
|
||||
"description": "Ошибка сервера",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Ошибка получения списка групп"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
async def get_groups(
|
||||
request: Request,
|
||||
api_key_header: Optional[bool] = require_api_key_optional
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Получить список всех групп.
|
||||
|
||||
Без API ключа: возвращает только названия групп без ID.
|
||||
С API ключом (заголовок X-API-Key): возвращает полную информацию о группах включая ID.
|
||||
|
||||
Подробная документация: см. docs/api/groups.md
|
||||
"""
|
||||
metrics.increment_api_endpoint("groups_list")
|
||||
|
||||
# Если API ключ валиден, возвращаем полную информацию
|
||||
include_ids = api_key_header is True
|
||||
|
||||
# Получаем группы
|
||||
try:
|
||||
groups_dict = await groups_config.get_all_groups(include_ids=include_ids)
|
||||
|
||||
# Формируем список групп
|
||||
groups = []
|
||||
for name, group_config in groups_dict.items():
|
||||
if include_ids:
|
||||
# Возвращаем полную информацию о группе
|
||||
if isinstance(group_config, dict) and group_config is not None:
|
||||
groups.append(GroupInfo(
|
||||
name=name,
|
||||
messenger=group_config.get("messenger"),
|
||||
chat_id=group_config.get("chat_id"),
|
||||
thread_id=group_config.get("thread_id")
|
||||
))
|
||||
else:
|
||||
# Если group_config is None, значит include_ids=False, но мы здесь не должны быть
|
||||
groups.append(GroupInfo(
|
||||
name=name,
|
||||
messenger=None,
|
||||
chat_id=None,
|
||||
thread_id=None
|
||||
))
|
||||
else:
|
||||
# Возвращаем только название группы (group_config будет None)
|
||||
groups.append(GroupInfo(
|
||||
name=name,
|
||||
messenger=None,
|
||||
chat_id=None,
|
||||
thread_id=None
|
||||
))
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"groups": [group.model_dump() for group in groups],
|
||||
"count": len(groups)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения списка групп: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка получения списка групп: {str(e)}")
|
||||
|
||||
|
||||
@require_api_key
|
||||
@router.post(
|
||||
"",
|
||||
name="Создать группу",
|
||||
response_model=Dict[str, Any],
|
||||
dependencies=[require_api_key_dependency],
|
||||
responses={
|
||||
200: {
|
||||
"description": "Группа успешно создана",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"status": "ok",
|
||||
"message": "Группа 'monitoring' создана с ID -1001234567890"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
"description": "Ошибка запроса (группа уже существует или неверные данные)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"group_exists": {
|
||||
"summary": "Группа уже существует",
|
||||
"value": {"detail": "Группа 'monitoring' уже существует"}
|
||||
},
|
||||
"invalid_data": {
|
||||
"summary": "Неверные данные",
|
||||
"value": {"detail": "Неверный формат данных"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
401: {
|
||||
"description": "Ошибка авторизации (неверный API ключ)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Неверный или отсутствующий API ключ"}
|
||||
}
|
||||
}
|
||||
},
|
||||
500: {
|
||||
"description": "Ошибка сервера",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Ошибка создания группы"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
async def create_group(
|
||||
request: Request,
|
||||
body: CreateGroupRequest = Body(
|
||||
...,
|
||||
examples=[
|
||||
{
|
||||
"group_name": "monitoring",
|
||||
"messenger": "telegram",
|
||||
"chat_id": -1001234567890,
|
||||
"thread_id": 0
|
||||
},
|
||||
{
|
||||
"group_name": "max_alerts",
|
||||
"messenger": "max",
|
||||
"chat_id": "123456789",
|
||||
"thread_id": 0,
|
||||
"config": {
|
||||
"access_token": "your_max_access_token"
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Создать новую группу в конфигурации.
|
||||
|
||||
Требуется API ключ в заголовке X-API-Key.
|
||||
|
||||
Подробная документация: см. docs/api/groups.md
|
||||
"""
|
||||
metrics.increment_api_endpoint("groups_create")
|
||||
|
||||
# Создаем группу
|
||||
try:
|
||||
success = await groups_config.create_group(
|
||||
group_name=body.group_name,
|
||||
chat_id=body.chat_id,
|
||||
messenger=body.messenger,
|
||||
thread_id=body.thread_id,
|
||||
config=body.config
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Группа '{body.group_name}' уже существует"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": f"Группа '{body.group_name}' создана с мессенджером '{body.messenger}' и ID {body.chat_id}"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания группы: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка создания группы: {str(e)}")
|
||||
|
||||
|
||||
@require_api_key
|
||||
@router.put(
|
||||
"/{group_name}",
|
||||
name="Обновить группу",
|
||||
response_model=Dict[str, Any],
|
||||
dependencies=[require_api_key_dependency],
|
||||
responses={
|
||||
200: {
|
||||
"description": "Группа успешно обновлена",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"status": "ok",
|
||||
"message": "Группа 'monitoring' обновлена с ID -1001234567891"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
"description": "Ошибка запроса (группа не найдена или неверные данные)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Группа 'monitoring' не найдена"}
|
||||
}
|
||||
}
|
||||
},
|
||||
401: {
|
||||
"description": "Ошибка авторизации (неверный API ключ)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Неверный или отсутствующий API ключ"}
|
||||
}
|
||||
}
|
||||
},
|
||||
500: {
|
||||
"description": "Ошибка сервера",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Ошибка обновления группы"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
async def update_group(
|
||||
request: Request,
|
||||
group_name: str = Path(
|
||||
...,
|
||||
description="Имя группы для обновления",
|
||||
examples=["monitoring", "alerts", "devops"]
|
||||
),
|
||||
body: UpdateGroupRequest = Body(
|
||||
...,
|
||||
examples=[
|
||||
{
|
||||
"chat_id": -1001234567891,
|
||||
"messenger": "telegram",
|
||||
"thread_id": 0
|
||||
},
|
||||
{
|
||||
"chat_id": "123456789",
|
||||
"messenger": "max",
|
||||
"config": {
|
||||
"access_token": "your_access_token",
|
||||
"api_version": "5.131"
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Обновить существующую группу в конфигурации.
|
||||
|
||||
Требуется API ключ в заголовке X-API-Key.
|
||||
|
||||
Подробная документация: см. docs/api/groups.md
|
||||
"""
|
||||
metrics.increment_api_endpoint("groups_update")
|
||||
|
||||
# Обновляем группу
|
||||
try:
|
||||
success = await groups_config.update_group(
|
||||
group_name=group_name,
|
||||
chat_id=body.chat_id,
|
||||
messenger=body.messenger,
|
||||
thread_id=body.thread_id,
|
||||
config=body.config
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Группа '{group_name}' не найдена"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": f"Группа '{group_name}' обновлена"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления группы: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка обновления группы: {str(e)}")
|
||||
|
||||
|
||||
@require_api_key
|
||||
@router.delete(
|
||||
"/{group_name}",
|
||||
name="Удалить группу",
|
||||
response_model=Dict[str, Any],
|
||||
dependencies=[require_api_key_dependency],
|
||||
responses={
|
||||
200: {
|
||||
"description": "Группа успешно удалена",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"status": "ok",
|
||||
"message": "Группа 'monitoring' удалена"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
"description": "Ошибка запроса (группа не найдена)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Группа 'monitoring' не найдена"}
|
||||
}
|
||||
}
|
||||
},
|
||||
401: {
|
||||
"description": "Ошибка авторизации (неверный API ключ)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Неверный или отсутствующий API ключ"}
|
||||
}
|
||||
}
|
||||
},
|
||||
500: {
|
||||
"description": "Ошибка сервера",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Ошибка удаления группы"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
async def delete_group(
|
||||
request: Request,
|
||||
group_name: str = Path(
|
||||
...,
|
||||
description="Имя группы для удаления",
|
||||
examples=["monitoring", "alerts", "devops"]
|
||||
)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Удалить группу из конфигурации.
|
||||
|
||||
Требуется API ключ в заголовке X-API-Key.
|
||||
|
||||
Подробная документация: см. docs/api/groups.md
|
||||
"""
|
||||
metrics.increment_api_endpoint("groups_delete")
|
||||
|
||||
# Удаляем группу
|
||||
try:
|
||||
success = await groups_config.delete_group(group_name)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Группа '{group_name}' не найдена"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": f"Группа '{group_name}' удалена"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления группы: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка удаления группы: {str(e)}")
|
||||
111
app/api/v1/endpoints/health.py
Normal file
111
app/api/v1/endpoints/health.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Эндпоинты для проверки здоровья приложения.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Dict, Any
|
||||
|
||||
from app.core.metrics import metrics
|
||||
from app.core.groups import groups_config
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/health", tags=["health"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
name="Проверка здоровья приложения",
|
||||
response_model=Dict[str, Any],
|
||||
responses={
|
||||
200: {
|
||||
"description": "Приложение работает и готово",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"healthy": {
|
||||
"summary": "Приложение здорово",
|
||||
"value": {
|
||||
"status": "healthy",
|
||||
"state": "online",
|
||||
"telegram_bot_configured": True,
|
||||
"groups_config_available": True
|
||||
}
|
||||
},
|
||||
"not_ready": {
|
||||
"summary": "Приложение не готово",
|
||||
"value": {
|
||||
"status": "not_ready",
|
||||
"state": "online",
|
||||
"checks": {
|
||||
"telegram_bot_configured": False,
|
||||
"groups_config_available": True
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
503: {
|
||||
"description": "Приложение не готово к работе",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"status": "not_ready",
|
||||
"checks": {
|
||||
"telegram_bot_configured": False,
|
||||
"groups_config_available": True
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
async def health_check() -> Dict[str, Any]:
|
||||
"""
|
||||
Проверка здоровья и готовности приложения для Kubernetes probes.
|
||||
|
||||
Объединенный endpoint для liveness и readiness probes.
|
||||
Не требует аутентификации.
|
||||
|
||||
Подробная документация: см. docs/api/health.md
|
||||
"""
|
||||
metrics.increment_api_endpoint("health")
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
checks = {
|
||||
"telegram_bot_configured": bool(settings.telegram_bot_token),
|
||||
"groups_config_available": False,
|
||||
}
|
||||
|
||||
# Проверяем доступность конфигурации групп
|
||||
try:
|
||||
await groups_config.refresh_cache()
|
||||
checks["groups_config_available"] = True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при проверке конфигурации групп: {e}")
|
||||
checks["groups_config_available"] = False
|
||||
|
||||
# Если не все проверки пройдены, возвращаем 503
|
||||
if not all(checks.values()):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail={
|
||||
"status": "not_ready",
|
||||
"state": "online",
|
||||
"checks": checks
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"state": "online",
|
||||
**checks
|
||||
}
|
||||
19
app/api/v1/endpoints/jira.py
Normal file
19
app/api/v1/endpoints/jira.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Эндпоинты для работы с Jira (только для внутреннего использования).
|
||||
|
||||
Создание тикетов по кнопке и работа с маппингом выполняются внутренними процессами.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from fastapi import APIRouter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/jira", tags=["jira"])
|
||||
|
||||
# Этот модуль оставлен пустым, так как все операции с Jira выполняются внутренними процессами
|
||||
# Создание тикетов происходит автоматически при обработке алертов (см. app/modules/*.py)
|
||||
# Маппинг загружается автоматически из config/jira_mapping.json (см. app/core/jira_mapping.py)
|
||||
468
app/api/v1/endpoints/message.py
Normal file
468
app/api/v1/endpoints/message.py
Normal file
@@ -0,0 +1,468 @@
|
||||
"""
|
||||
Эндпоинты для отправки простых сообщений в мессенджеры.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, Body
|
||||
|
||||
from app.core.metrics import metrics
|
||||
from app.core.groups import groups_config
|
||||
from app.core.messenger_factory import MessengerFactory
|
||||
from app.core.auth import require_api_key, require_api_key_dependency
|
||||
from app.models.message import (
|
||||
SendMessageRequest,
|
||||
SendPhotoRequest,
|
||||
SendVideoRequest,
|
||||
SendAudioRequest,
|
||||
SendDocumentRequest
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/message", tags=["message"])
|
||||
|
||||
|
||||
async def _send_message_to_group(
|
||||
group_name: str,
|
||||
thread_id: int,
|
||||
messenger: Optional[str],
|
||||
send_func,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Вспомогательная функция для отправки сообщений в группу.
|
||||
|
||||
Args:
|
||||
group_name: Имя группы из конфигурации.
|
||||
thread_id: ID треда в группе (0 для основной группы).
|
||||
messenger: Тип мессенджера (опционально).
|
||||
send_func: Функция отправки сообщения.
|
||||
*args: Дополнительные аргументы для функции отправки.
|
||||
**kwargs: Дополнительные параметры для функции отправки.
|
||||
|
||||
Returns:
|
||||
Результат отправки сообщения.
|
||||
|
||||
Raises:
|
||||
HTTPException: Если группа не найдена или произошла ошибка отправки.
|
||||
"""
|
||||
# Получаем конфигурацию группы
|
||||
group_config = await groups_config.get_group_config(group_name, messenger)
|
||||
if group_config is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Группа '{group_name}' не найдена в конфигурации"
|
||||
)
|
||||
|
||||
messenger_type = group_config.get("messenger", "telegram")
|
||||
chat_id = group_config.get("chat_id")
|
||||
group_thread_id = group_config.get("thread_id", 0)
|
||||
|
||||
# Используем thread_id из параметра, если указан, иначе из конфигурации группы
|
||||
final_thread_id = thread_id if thread_id > 0 else group_thread_id
|
||||
|
||||
# Создаем клиент мессенджера
|
||||
messenger_client = MessengerFactory.create_from_config(group_config)
|
||||
|
||||
# Проверяем поддержку тредов
|
||||
if not messenger_client.supports_threads and final_thread_id > 0:
|
||||
logger.warning(f"Мессенджер '{messenger_type}' не поддерживает треды, thread_id будет проигнорирован")
|
||||
final_thread_id = None
|
||||
elif final_thread_id == 0:
|
||||
final_thread_id = None
|
||||
|
||||
# Вызываем функцию отправки
|
||||
success = await send_func(
|
||||
messenger_client,
|
||||
chat_id=chat_id,
|
||||
thread_id=final_thread_id,
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка отправки сообщения в {messenger_type}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": f"Сообщение отправлено в чат {group_name}, тред {thread_id if thread_id > 0 else 0}"
|
||||
}
|
||||
|
||||
|
||||
@require_api_key
|
||||
@router.post(
|
||||
"/text",
|
||||
name="Отправить текстовое сообщение",
|
||||
response_model=Dict[str, Any],
|
||||
dependencies=[require_api_key_dependency],
|
||||
responses={
|
||||
200: {
|
||||
"description": "Сообщение отправлено успешно",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"status": "ok",
|
||||
"message": "Сообщение отправлено в чат monitoring, тред 0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
"description": "Ошибка запроса",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
|
||||
}
|
||||
}
|
||||
},
|
||||
401: {
|
||||
"description": "Требуется API ключ",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Неверный или отсутствующий API ключ"}
|
||||
}
|
||||
}
|
||||
},
|
||||
500: {
|
||||
"description": "Ошибка отправки сообщения",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Ошибка отправки сообщения в telegram"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
async def send_text_message(
|
||||
request: Request,
|
||||
body: SendMessageRequest = Body(...),
|
||||
messenger: Optional[str] = Query(None, description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы", examples=["telegram", "max"])
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Отправить текстовое сообщение в группу мессенджера.
|
||||
|
||||
Подробная документация: см. docs/api/message.md
|
||||
"""
|
||||
metrics.increment_api_endpoint("message_text")
|
||||
|
||||
async def send_func(client, chat_id, thread_id, **kwargs):
|
||||
return await client.send_message(
|
||||
chat_id=chat_id,
|
||||
text=body.text,
|
||||
thread_id=thread_id,
|
||||
disable_web_page_preview=body.disable_web_page_preview,
|
||||
parse_mode=body.parse_mode
|
||||
)
|
||||
|
||||
return await _send_message_to_group(
|
||||
group_name=body.tg_group,
|
||||
thread_id=body.tg_thread_id,
|
||||
messenger=messenger,
|
||||
send_func=send_func
|
||||
)
|
||||
|
||||
|
||||
@require_api_key
|
||||
@router.post(
|
||||
"/photo",
|
||||
name="Отправить фото",
|
||||
response_model=Dict[str, Any],
|
||||
dependencies=[require_api_key_dependency],
|
||||
responses={
|
||||
200: {
|
||||
"description": "Фото отправлено успешно",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"status": "ok",
|
||||
"message": "Фото отправлено в чат monitoring, тред 0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
"description": "Ошибка запроса",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
|
||||
}
|
||||
}
|
||||
},
|
||||
401: {
|
||||
"description": "Требуется API ключ",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Неверный или отсутствующий API ключ"}
|
||||
}
|
||||
}
|
||||
},
|
||||
500: {
|
||||
"description": "Ошибка отправки фото",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Ошибка отправки фото в telegram"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
async def send_photo(
|
||||
request: Request,
|
||||
body: SendPhotoRequest = Body(...),
|
||||
messenger: Optional[str] = Query(None, description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы", examples=["telegram", "max"])
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Отправить фото в группу мессенджера.
|
||||
|
||||
Подробная документация: см. docs/api/message.md
|
||||
"""
|
||||
metrics.increment_api_endpoint("message_photo")
|
||||
|
||||
async def send_func(client, chat_id, thread_id, **kwargs):
|
||||
return await client.send_photo(
|
||||
chat_id=chat_id,
|
||||
photo=body.photo,
|
||||
caption=body.caption,
|
||||
thread_id=thread_id,
|
||||
parse_mode=body.parse_mode
|
||||
)
|
||||
|
||||
return await _send_message_to_group(
|
||||
group_name=body.tg_group,
|
||||
thread_id=body.tg_thread_id,
|
||||
messenger=messenger,
|
||||
send_func=send_func
|
||||
)
|
||||
|
||||
|
||||
@require_api_key
|
||||
@router.post(
|
||||
"/video",
|
||||
name="Отправить видео",
|
||||
response_model=Dict[str, Any],
|
||||
dependencies=[require_api_key_dependency],
|
||||
responses={
|
||||
200: {
|
||||
"description": "Видео отправлено успешно",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"status": "ok",
|
||||
"message": "Видео отправлено в чат monitoring, тред 0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
"description": "Ошибка запроса",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
|
||||
}
|
||||
}
|
||||
},
|
||||
401: {
|
||||
"description": "Требуется API ключ",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Неверный или отсутствующий API ключ"}
|
||||
}
|
||||
}
|
||||
},
|
||||
500: {
|
||||
"description": "Ошибка отправки видео",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Ошибка отправки видео в telegram"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
async def send_video(
|
||||
request: Request,
|
||||
body: SendVideoRequest = Body(...),
|
||||
messenger: Optional[str] = Query(None, description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы", examples=["telegram", "max"])
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Отправить видео в группу мессенджера.
|
||||
|
||||
Подробная документация: см. docs/api/message.md
|
||||
"""
|
||||
metrics.increment_api_endpoint("message_video")
|
||||
|
||||
async def send_func(client, chat_id, thread_id, **kwargs):
|
||||
return await client.send_video(
|
||||
chat_id=chat_id,
|
||||
video=body.video,
|
||||
caption=body.caption,
|
||||
thread_id=thread_id,
|
||||
parse_mode=body.parse_mode,
|
||||
duration=body.duration,
|
||||
width=body.width,
|
||||
height=body.height
|
||||
)
|
||||
|
||||
return await _send_message_to_group(
|
||||
group_name=body.tg_group,
|
||||
thread_id=body.tg_thread_id,
|
||||
messenger=messenger,
|
||||
send_func=send_func
|
||||
)
|
||||
|
||||
|
||||
@require_api_key
|
||||
@router.post(
|
||||
"/audio",
|
||||
name="Отправить аудио",
|
||||
response_model=Dict[str, Any],
|
||||
dependencies=[require_api_key_dependency],
|
||||
responses={
|
||||
200: {
|
||||
"description": "Аудио отправлено успешно",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"status": "ok",
|
||||
"message": "Аудио отправлено в чат monitoring, тред 0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
"description": "Ошибка запроса",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
|
||||
}
|
||||
}
|
||||
},
|
||||
401: {
|
||||
"description": "Требуется API ключ",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Неверный или отсутствующий API ключ"}
|
||||
}
|
||||
}
|
||||
},
|
||||
500: {
|
||||
"description": "Ошибка отправки аудио",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Ошибка отправки аудио в telegram"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
async def send_audio(
|
||||
request: Request,
|
||||
body: SendAudioRequest = Body(...),
|
||||
messenger: Optional[str] = Query(None, description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы", examples=["telegram", "max"])
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Отправить аудио в группу мессенджера.
|
||||
|
||||
Подробная документация: см. docs/api/message.md
|
||||
"""
|
||||
metrics.increment_api_endpoint("message_audio")
|
||||
|
||||
async def send_func(client, chat_id, thread_id, **kwargs):
|
||||
return await client.send_audio(
|
||||
chat_id=chat_id,
|
||||
audio=body.audio,
|
||||
caption=body.caption,
|
||||
thread_id=thread_id,
|
||||
parse_mode=body.parse_mode,
|
||||
duration=body.duration,
|
||||
performer=body.performer,
|
||||
title=body.title
|
||||
)
|
||||
|
||||
return await _send_message_to_group(
|
||||
group_name=body.tg_group,
|
||||
thread_id=body.tg_thread_id,
|
||||
messenger=messenger,
|
||||
send_func=send_func
|
||||
)
|
||||
|
||||
|
||||
@require_api_key
|
||||
@router.post(
|
||||
"/document",
|
||||
name="Отправить документ",
|
||||
response_model=Dict[str, Any],
|
||||
dependencies=[require_api_key_dependency],
|
||||
responses={
|
||||
200: {
|
||||
"description": "Документ отправлен успешно",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"status": "ok",
|
||||
"message": "Документ отправлен в чат monitoring, тред 0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
"description": "Ошибка запроса",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
|
||||
}
|
||||
}
|
||||
},
|
||||
401: {
|
||||
"description": "Требуется API ключ",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Неверный или отсутствующий API ключ"}
|
||||
}
|
||||
}
|
||||
},
|
||||
500: {
|
||||
"description": "Ошибка отправки документа",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Ошибка отправки документа в telegram"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
async def send_document(
|
||||
request: Request,
|
||||
body: SendDocumentRequest = Body(...),
|
||||
messenger: Optional[str] = Query(None, description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы", examples=["telegram", "max"])
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Отправить документ в группу мессенджера.
|
||||
|
||||
Подробная документация: см. docs/api/message.md
|
||||
"""
|
||||
metrics.increment_api_endpoint("message_document")
|
||||
|
||||
async def send_func(client, chat_id, thread_id, **kwargs):
|
||||
return await client.send_document(
|
||||
chat_id=chat_id,
|
||||
document=body.document,
|
||||
caption=body.caption,
|
||||
thread_id=thread_id,
|
||||
parse_mode=body.parse_mode,
|
||||
filename=body.filename
|
||||
)
|
||||
|
||||
return await _send_message_to_group(
|
||||
group_name=body.tg_group,
|
||||
thread_id=body.tg_thread_id,
|
||||
messenger=messenger,
|
||||
send_func=send_func
|
||||
)
|
||||
403
app/api/v1/endpoints/monitoring.py
Normal file
403
app/api/v1/endpoints/monitoring.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""
|
||||
Эндпоинты для обработки webhooks из систем мониторинга (Grafana, Zabbix, AlertManager).
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Path, Body, Query
|
||||
from typing import Dict, Optional
|
||||
|
||||
from app.models.grafana import GrafanaAlert
|
||||
from app.models.zabbix import ZabbixAlert
|
||||
from app.models.alertmanager import PrometheusAlert
|
||||
from app.modules.grafana import send as grafana_send
|
||||
from app.modules.zabbix import send as zabbix_send
|
||||
from app.modules.alertmanager import send as alertmanager_send
|
||||
from app.core.metrics import metrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["monitoring"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/grafana/{group_name}/{thread_id}",
|
||||
name="Отправка вебхуков из Grafana",
|
||||
response_model=Dict[str, str],
|
||||
summary="Отправить алерт из Grafana",
|
||||
description="Эндпоинт для обработки webhooks из Grafana. **Не требует авторизации.**",
|
||||
responses={
|
||||
200: {
|
||||
"description": "Сообщение успешно отправлено",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"status": "ok", "message": "Сообщение отправлено"}
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
"description": "Некорректные данные запроса",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Неверный формат данных для Grafana алерта"}
|
||||
}
|
||||
}
|
||||
},
|
||||
404: {
|
||||
"description": "Группа не найдена",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
|
||||
}
|
||||
}
|
||||
},
|
||||
500: {
|
||||
"description": "Ошибка сервера",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Ошибка отправки сообщения"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
async def send_grafana_alert(
|
||||
group_name: str = Path(
|
||||
...,
|
||||
description="Имя группы из конфигурации (config/groups.json)",
|
||||
examples=["monitoring", "alerts", "devops"]
|
||||
),
|
||||
thread_id: int = Path(
|
||||
...,
|
||||
description="ID треда в группе (0 для отправки в основную группу без треда, поддерживается только для Telegram)",
|
||||
examples=[0, 123, 456]
|
||||
),
|
||||
messenger: Optional[str] = Query(
|
||||
None,
|
||||
description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы",
|
||||
examples=["telegram", "max"]
|
||||
),
|
||||
alert: GrafanaAlert = Body(
|
||||
...,
|
||||
description="Данные алерта из Grafana",
|
||||
examples=[
|
||||
{
|
||||
"title": "[Alerting] High CPU Usage",
|
||||
"ruleId": 674180201771804383,
|
||||
"ruleName": "High CPU Usage Alert",
|
||||
"state": "alerting",
|
||||
"evalMatches": [
|
||||
{
|
||||
"value": 95.5,
|
||||
"metric": "cpu_usage_percent",
|
||||
"tags": {"host": "server01", "instance": "production"}
|
||||
}
|
||||
],
|
||||
"orgId": 1,
|
||||
"dashboardId": 123,
|
||||
"panelId": 456,
|
||||
"tags": {"severity": "critical", "environment": "production"},
|
||||
"ruleUrl": "http://grafana.cism-ms.ru/alerting/list",
|
||||
"message": "CPU usage is above 90% threshold for more than 5 minutes"
|
||||
},
|
||||
{
|
||||
"title": "[OK] High CPU Usage",
|
||||
"ruleId": 674180201771804383,
|
||||
"ruleName": "High CPU Usage Alert",
|
||||
"state": "ok",
|
||||
"evalMatches": [
|
||||
{
|
||||
"value": 45.2,
|
||||
"metric": "cpu_usage_percent",
|
||||
"tags": {"host": "server01", "instance": "production"}
|
||||
}
|
||||
],
|
||||
"orgId": 1,
|
||||
"dashboardId": 123,
|
||||
"panelId": 456,
|
||||
"tags": {"severity": "critical", "environment": "production"},
|
||||
"ruleUrl": "http://grafana.cism-ms.ru/alerting/list",
|
||||
"message": "CPU usage has returned to normal levels"
|
||||
}
|
||||
]
|
||||
)
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Отправить алерт из Grafana в мессенджер.
|
||||
|
||||
Принимает webhook от Grafana и отправляет сообщение в указанную группу мессенджера.
|
||||
Не требует авторизации (API ключ не нужен).
|
||||
|
||||
Подробная документация: см. docs/monitoring/grafana.md
|
||||
"""
|
||||
metrics.increment_api_endpoint("grafana")
|
||||
logger.info(f"Получен алерт Grafana для группы {group_name}, тред {thread_id}, мессенджер {messenger}")
|
||||
|
||||
try:
|
||||
await grafana_send(group_name, thread_id, alert, messenger)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Сообщение отправлено"
|
||||
}
|
||||
except ValueError as e:
|
||||
logger.error(f"Ошибка валидации: {e}")
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки алерта Grafana: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка отправки сообщения: {str(e)}")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/zabbix/{group_name}/{thread_id}",
|
||||
name="Отправка вебхуков из Zabbix",
|
||||
response_model=Dict[str, str],
|
||||
summary="Отправить алерт из Zabbix",
|
||||
description="Эндпоинт для обработки webhooks из Zabbix. **Не требует авторизации.**",
|
||||
responses={
|
||||
200: {
|
||||
"description": "Сообщение успешно отправлено",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"status": "ok", "message": "Сообщение отправлено"}
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
"description": "Некорректные данные запроса",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Неверный формат данных для Zabbix алерта"}
|
||||
}
|
||||
}
|
||||
},
|
||||
404: {
|
||||
"description": "Группа не найдена",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
|
||||
}
|
||||
}
|
||||
},
|
||||
500: {
|
||||
"description": "Ошибка сервера",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Ошибка отправки сообщения"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
async def send_zabbix_alert(
|
||||
group_name: str = Path(
|
||||
...,
|
||||
description="Имя группы из конфигурации (config/groups.json)",
|
||||
examples=["monitoring", "alerts", "devops"]
|
||||
),
|
||||
thread_id: int = Path(
|
||||
...,
|
||||
description="ID треда в группе (0 для отправки в основную группу без треда, поддерживается только для Telegram)",
|
||||
examples=[0, 123, 456]
|
||||
),
|
||||
messenger: Optional[str] = Query(
|
||||
None,
|
||||
description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы",
|
||||
examples=["telegram", "max"]
|
||||
),
|
||||
alert: ZabbixAlert = Body(
|
||||
...,
|
||||
description="Данные алерта из Zabbix",
|
||||
examples=[
|
||||
{
|
||||
"link": "https://zabbix.example.com/tr_events.php?triggerid=42667&eventid=8819711",
|
||||
"status": "PROBLEM",
|
||||
"action-id": "7",
|
||||
"alert-subject": "Problem: High CPU utilization (over 90% for 5m)",
|
||||
"alert-message": "Problem started at 16:48:44 on 2024.02.08\r\nProblem name: High CPU utilization (over 90% for 5m)\r\nHost: pnode28\r\nSeverity: Warning\r\nCurrent utilization: 95.2 %\r\n",
|
||||
"event-id": "8819711",
|
||||
"event-name": "High CPU utilization (over 90% for 5m)",
|
||||
"event-nseverity": "2",
|
||||
"event-opdata": "Current utilization: 95.2 %",
|
||||
"event-severity": "Warning",
|
||||
"host-name": "pnode28",
|
||||
"host-ip": "10.14.253.38",
|
||||
"host-port": "10050"
|
||||
},
|
||||
{
|
||||
"link": "https://zabbix.example.com/tr_events.php?triggerid=42667&eventid=8819711",
|
||||
"status": "OK",
|
||||
"action-id": "7",
|
||||
"alert-subject": "Resolved in 1m 0s: High CPU utilization (over 90% for 5m)",
|
||||
"alert-message": "Problem has been resolved at 16:49:44 on 2024.02.08\r\nProblem name: High CPU utilization (over 90% for 5m)\r\nProblem duration: 1m 0s\r\nHost: pnode28\r\nSeverity: Warning\r\nOriginal problem ID: 8819711\r\n",
|
||||
"event-id": "8819711",
|
||||
"event-name": "High CPU utilization (over 90% for 5m)",
|
||||
"event-nseverity": "2",
|
||||
"event-opdata": "Current utilization: 70.9 %",
|
||||
"event-recovery-date": "2024.02.08",
|
||||
"event-recovery-time": "16:49:44",
|
||||
"event-duration": "1m 0s",
|
||||
"event-recovery-name": "High CPU utilization (over 90% for 5m)",
|
||||
"event-recovery-status": "RESOLVED",
|
||||
"event-recovery-tags": "Application:CPU",
|
||||
"event-severity": "Warning",
|
||||
"host-name": "pnode28",
|
||||
"host-ip": "10.14.253.38",
|
||||
"host-port": "10050"
|
||||
}
|
||||
]
|
||||
)
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Отправить алерт из Zabbix в мессенджер.
|
||||
|
||||
Принимает webhook от Zabbix и отправляет сообщение в указанную группу мессенджера.
|
||||
Не требует авторизации (API ключ не нужен).
|
||||
|
||||
Подробная документация: см. docs/monitoring/zabbix.md
|
||||
"""
|
||||
metrics.increment_api_endpoint("zabbix")
|
||||
logger.info(f"Получен алерт Zabbix для группы {group_name}, тред {thread_id}, мессенджер {messenger}")
|
||||
|
||||
try:
|
||||
await zabbix_send(group_name, thread_id, alert, messenger)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Сообщение отправлено"
|
||||
}
|
||||
except ValueError as e:
|
||||
logger.error(f"Ошибка валидации: {e}")
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки алерта Zabbix: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка отправки сообщения: {str(e)}")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/alertmanager/{k8s_cluster}/{group_name}/{thread_id}",
|
||||
name="Отправка вебхуков из AlertManager",
|
||||
response_model=Dict[str, str],
|
||||
summary="Отправить алерт из AlertManager",
|
||||
description="Эндпоинт для обработки webhooks из AlertManager. **Не требует авторизации.**",
|
||||
responses={
|
||||
200: {
|
||||
"description": "Сообщение успешно отправлено",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"status": "ok", "message": "Сообщение отправлено"}
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
"description": "Некорректные данные запроса",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Неверный формат данных для AlertManager алерта"}
|
||||
}
|
||||
}
|
||||
},
|
||||
404: {
|
||||
"description": "Группа не найдена",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
|
||||
}
|
||||
}
|
||||
},
|
||||
500: {
|
||||
"description": "Ошибка сервера",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Ошибка отправки сообщения"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
async def send_alertmanager_alert(
|
||||
k8s_cluster: str = Path(
|
||||
...,
|
||||
description="Имя Kubernetes кластера (используется для формирования URL к Grafana/Prometheus)",
|
||||
examples=["production", "staging", "development"]
|
||||
),
|
||||
group_name: str = Path(
|
||||
...,
|
||||
description="Имя группы из конфигурации (config/groups.json)",
|
||||
examples=["monitoring", "alerts", "devops"]
|
||||
),
|
||||
thread_id: int = Path(
|
||||
...,
|
||||
description="ID треда в группе (0 для отправки в основную группу без треда, поддерживается только для Telegram)",
|
||||
examples=[0, 123, 456]
|
||||
),
|
||||
messenger: Optional[str] = Query(
|
||||
None,
|
||||
description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы",
|
||||
examples=["telegram", "max"]
|
||||
),
|
||||
alert: PrometheusAlert = Body(
|
||||
...,
|
||||
description="Данные алерта из AlertManager",
|
||||
examples=[
|
||||
{
|
||||
"status": "firing",
|
||||
"externalURL": "http://alertmanager.example.com",
|
||||
"commonLabels": {
|
||||
"alertname": "HighCPUUsage",
|
||||
"severity": "critical",
|
||||
"namespace": "production",
|
||||
"pod": "app-deployment-7d8f9b4c5-abc123",
|
||||
"container": "app-container"
|
||||
},
|
||||
"commonAnnotations": {
|
||||
"summary": "High CPU usage detected in production namespace",
|
||||
"description": "CPU usage is above 90% for 5 minutes on pod app-deployment-7d8f9b4c5-abc123",
|
||||
"runbook_url": "https://wiki.example.com/runbooks/high-cpu-usage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": "resolved",
|
||||
"externalURL": "http://alertmanager.example.com",
|
||||
"commonLabels": {
|
||||
"alertname": "HighCPUUsage",
|
||||
"severity": "critical",
|
||||
"namespace": "production",
|
||||
"pod": "app-deployment-7d8f9b4c5-abc123",
|
||||
"container": "app-container"
|
||||
},
|
||||
"commonAnnotations": {
|
||||
"summary": "High CPU usage resolved in production namespace",
|
||||
"description": "CPU usage has returned to normal levels on pod app-deployment-7d8f9b4c5-abc123"
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Отправить алерт из AlertManager в мессенджер.
|
||||
|
||||
Эндпоинт для обработки webhooks из AlertManager.
|
||||
Не требует авторизации (API ключ не нужен).
|
||||
|
||||
Подробная документация: см. docs/monitoring/alertmanager.md
|
||||
"""
|
||||
metrics.increment_api_endpoint("alertmanager")
|
||||
logger.info(f"Получен алерт AlertManager для кластера {k8s_cluster}, группы {group_name}, тред {thread_id}, мессенджер {messenger}")
|
||||
|
||||
try:
|
||||
if not isinstance(alert, PrometheusAlert):
|
||||
raise HTTPException(status_code=400, detail="Неверный формат данных для AlertManager алерта")
|
||||
await alertmanager_send(k8s_cluster, group_name, thread_id, alert, messenger)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Сообщение отправлено"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
logger.error(f"Ошибка валидации: {e}")
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки алерта AlertManager: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка отправки сообщения: {str(e)}")
|
||||
|
||||
27
app/api/v1/router.py
Normal file
27
app/api/v1/router.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Роутер API версии 1.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import (
|
||||
health,
|
||||
monitoring,
|
||||
debug,
|
||||
jira,
|
||||
message,
|
||||
groups,
|
||||
)
|
||||
|
||||
# Создаем роутер для API v1
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
# Подключаем эндпоинты
|
||||
api_router.include_router(health.router)
|
||||
api_router.include_router(monitoring.router)
|
||||
api_router.include_router(debug.router)
|
||||
api_router.include_router(jira.router)
|
||||
api_router.include_router(message.router)
|
||||
api_router.include_router(groups.router)
|
||||
0
app/common/__init__.py
Normal file
0
app/common/__init__.py
Normal file
34
app/common/cors.py
Normal file
34
app/common/cors.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Настройка CORS для приложения.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from typing import List
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def add(app):
|
||||
"""
|
||||
Добавить CORS middleware к приложению FastAPI.
|
||||
|
||||
Args:
|
||||
app: Экземпляр приложения FastAPI.
|
||||
"""
|
||||
# Разрешаем все источники (можно настроить через переменные окружения)
|
||||
allow_origins: List[str] = ["*"]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=allow_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
logger.info("CORS middleware добавлен")
|
||||
41
app/common/logger.py
Normal file
41
app/common/logger.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Настройка логирования приложения.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from opentelemetry.instrumentation.logging import LoggingInstrumentor
|
||||
|
||||
# Настраиваем уровень логирования сначала
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Настройка логирования с поддержкой OpenTelemetry
|
||||
try:
|
||||
from app.core.config import settings
|
||||
|
||||
if settings.otel_enabled:
|
||||
LoggingInstrumentor().instrument(
|
||||
set_logging_format=True,
|
||||
logging_format='%(levelname)s:\t %(message)s traceID=%(otelTraceID)s spanID=%(otelSpanID)s'
|
||||
)
|
||||
logger.info("OpenTelemetry логирование включено")
|
||||
else:
|
||||
LoggingInstrumentor().instrument(
|
||||
set_logging_format=True,
|
||||
logging_format='%(levelname)s:\t %(message)s'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка настройки OpenTelemetry логирования: {e}")
|
||||
# Используем базовое логирование
|
||||
LoggingInstrumentor().instrument(
|
||||
set_logging_format=True,
|
||||
logging_format='%(levelname)s:\t %(message)s'
|
||||
)
|
||||
|
||||
32
app/common/metrics.py
Normal file
32
app/common/metrics.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from prometheus_client import make_asgi_app, Counter, Gauge, CollectorRegistry, push_to_gateway # Добавляем метрики для прометея
|
||||
from fastapi import Request
|
||||
|
||||
import os
|
||||
|
||||
#def add_middleware(app):
|
||||
# # Обьявляем метрики, которые будем собирать
|
||||
# registry = getMetricsRegistry()
|
||||
# all_requests = Counter('tg_monitoring_gateway_all_requests_counter', 'Счетчик запросов', registry=registry)
|
||||
# # Запускаем счетчик запросов
|
||||
# @app.middleware("request_count")
|
||||
# def request_count(request: Request, call_next):
|
||||
# all_requests.inc()
|
||||
# pushMetricsRegistry(registry, all_requests)
|
||||
# response = call_next(request)
|
||||
# return response
|
||||
|
||||
#def getMetricsRegistry():
|
||||
# registry = CollectorRegistry()
|
||||
# return(registry)
|
||||
|
||||
#def pushMetricsRegistry(registry, metric):
|
||||
# pushgateway_url = os.getenv('PUSHGATEWAY_URL')
|
||||
# pushgateway_job = os.getenv('PUSHGATEWAY_JOB')
|
||||
# push_to_gateway(pushgateway_url, job=pushgateway_job, registry=registry)
|
||||
# return(metric)
|
||||
|
||||
#def requestsCount(registry, endpoint):
|
||||
# all_requests = Counter('tg_monitoring_gateway_api_requests_counter', 'Счетчик запросов к API', labelnames=['endpoint'], registry=registry)
|
||||
# all_requests.labels(endpoint=endpoint).inc()
|
||||
# pushMetricsRegistry(registry, all_requests)
|
||||
# return(endpoint)
|
||||
69
app/common/telemetry.py
Normal file
69
app/common/telemetry.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Настройка телеметрии OpenTelemetry.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_telemetry():
|
||||
"""
|
||||
Настройка провайдера телеметрии OpenTelemetry.
|
||||
|
||||
Returns:
|
||||
TracerProvider: Провайдер трейсинга.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
resource = Resource(attributes={SERVICE_NAME: settings.otel_service_name})
|
||||
sampler = TraceIdRatioBased(1.0) # 100% семплирование
|
||||
|
||||
# Настройка экспортера OTLP
|
||||
exporter_config = {}
|
||||
if settings.otel_exporter_otlp_endpoint:
|
||||
exporter_config['endpoint'] = settings.otel_exporter_otlp_endpoint
|
||||
exporter_config['insecure'] = settings.otel_exporter_otlp_insecure
|
||||
|
||||
exporter = OTLPSpanExporter(**exporter_config)
|
||||
|
||||
provider = TracerProvider(sampler=sampler, resource=resource)
|
||||
processor = SimpleSpanProcessor(exporter)
|
||||
provider.add_span_processor(processor)
|
||||
trace.set_tracer_provider(provider)
|
||||
|
||||
logger.info(f"Telemetry настроен для сервиса: {settings.otel_service_name}")
|
||||
return provider
|
||||
|
||||
|
||||
def add(app):
|
||||
"""
|
||||
Добавить инструментацию OpenTelemetry к приложению FastAPI.
|
||||
|
||||
Args:
|
||||
app: Экземпляр приложения FastAPI.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
if settings.otel_enabled:
|
||||
try:
|
||||
tracer_provider = setup_telemetry()
|
||||
FastAPIInstrumentor.instrument_app(
|
||||
app,
|
||||
tracer_provider=tracer_provider,
|
||||
excluded_urls="/openapi.json,/docs,/redoc"
|
||||
)
|
||||
logger.info("OpenTelemetry инструментация добавлена")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка настройки OpenTelemetry: {e}")
|
||||
else:
|
||||
logger.info("OpenTelemetry отключен")
|
||||
6
app/core/__init__.py
Normal file
6
app/core/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Общие утилиты и функции для приложения.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
183
app/core/auth.py
Normal file
183
app/core/auth.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Утилиты для аутентификации и авторизации.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Callable, Any
|
||||
from functools import wraps
|
||||
from fastapi import HTTPException, Security, Depends, Request
|
||||
from fastapi.security import APIKeyHeader
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Request может быть как из FastAPI, так и из Starlette
|
||||
# Оба типа совместимы, поэтому используем StarletteRequest как базовый тип
|
||||
RequestType = StarletteRequest
|
||||
|
||||
# Схема безопасности для API ключа
|
||||
api_key_header = APIKeyHeader(
|
||||
name="X-API-Key",
|
||||
auto_error=False,
|
||||
description="API ключ для авторизации"
|
||||
)
|
||||
|
||||
|
||||
def verify_api_key(api_key: Optional[str] = Security(api_key_header)) -> bool:
|
||||
"""
|
||||
Проверить API ключ для авторизации (обязательная авторизация).
|
||||
|
||||
Используется как зависимость FastAPI (Depends) для отображения в Swagger UI.
|
||||
Также помечает контекст запроса, что API ключ был проверен.
|
||||
|
||||
Args:
|
||||
api_key: API ключ из заголовка X-API-Key.
|
||||
|
||||
Returns:
|
||||
True если API ключ верный.
|
||||
|
||||
Raises:
|
||||
HTTPException: Если API ключ неверный или не указан.
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
# Если API ключ не установлен в настройках, доступ запрещен
|
||||
if not settings.api_key:
|
||||
logger.warning("API_KEY не установлен - доступ запрещен")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="API ключ не настроен на сервере"
|
||||
)
|
||||
|
||||
# Если API ключ не передан, доступ запрещен
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Неверный или отсутствующий API ключ",
|
||||
headers={"WWW-Authenticate": "ApiKey"}
|
||||
)
|
||||
|
||||
# Проверяем API ключ
|
||||
if api_key != settings.api_key:
|
||||
logger.warning(f"Неверный API ключ: {api_key[:10]}...")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Неверный или отсутствующий API ключ",
|
||||
headers={"WWW-Authenticate": "ApiKey"}
|
||||
)
|
||||
|
||||
# Помечаем, что API ключ был проверен через dependency
|
||||
# Это позволяет декоратору @require_api_key не выполнять повторную проверку
|
||||
try:
|
||||
from starlette.context import contextvars
|
||||
# Используем contextvars для хранения информации о проверке
|
||||
# Но это может не работать во всех случаях
|
||||
pass
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def verify_api_key_optional(api_key: Optional[str] = Security(api_key_header)) -> Optional[bool]:
|
||||
"""
|
||||
Проверить API ключ для авторизации (опциональная авторизация).
|
||||
|
||||
Используется как зависимость FastAPI (Depends).
|
||||
|
||||
Args:
|
||||
api_key: API ключ из заголовка X-API-Key.
|
||||
|
||||
Returns:
|
||||
True если API ключ верный, None если не передан, выбрасывает исключение если неверный.
|
||||
|
||||
Raises:
|
||||
HTTPException: Если API ключ неверный.
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
# Если API ключ не установлен в настройках, возвращаем None (нет авторизации)
|
||||
if not settings.api_key:
|
||||
return None
|
||||
|
||||
# Если API ключ не передан, возвращаем None (нет авторизации)
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
# Проверяем API ключ
|
||||
if api_key != settings.api_key:
|
||||
logger.warning(f"Неверный API ключ: {api_key[:10]}...")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Неверный или отсутствующий API ключ",
|
||||
headers={"WWW-Authenticate": "ApiKey"}
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# Удобные константы для использования в endpoints (через dependencies)
|
||||
require_api_key_dependency = Depends(verify_api_key)
|
||||
require_api_key_optional = Depends(verify_api_key_optional)
|
||||
|
||||
|
||||
def require_api_key(func: Callable) -> Callable:
|
||||
"""
|
||||
Декоратор для пометки функции как требующей API ключ.
|
||||
|
||||
Использование:
|
||||
@require_api_key
|
||||
@router.post("/endpoint", dependencies=[require_api_key_dependency])
|
||||
async def my_endpoint(request: Request, ...):
|
||||
...
|
||||
|
||||
Примечание: Декоратор используется только для пометки функции.
|
||||
Фактическая проверка API ключа выполняется через `dependencies=[require_api_key_dependency]`,
|
||||
который также обеспечивает отображение замочка в Swagger UI.
|
||||
|
||||
Декоратор не выполняет проверку API ключа - это делает dependency.
|
||||
Декоратор оставлен для удобства и возможного расширения в будущем.
|
||||
|
||||
Args:
|
||||
func: Функция для декорирования.
|
||||
|
||||
Returns:
|
||||
Декорированная функция с пометкой о необходимости API ключа.
|
||||
"""
|
||||
# Помечаем функцию, что она требует API ключ
|
||||
func.__requires_api_key__ = True
|
||||
|
||||
# Просто возвращаем функцию без изменений
|
||||
# Проверка API ключа выполняется через dependency
|
||||
return func
|
||||
|
||||
|
||||
def hide_from_api(func: Callable) -> Callable:
|
||||
"""
|
||||
Декоратор для скрытия эндпоинта из API документации (Swagger UI).
|
||||
|
||||
Использование:
|
||||
@hide_from_api
|
||||
@router.post("/debug/dump")
|
||||
async def debug_endpoint(...):
|
||||
...
|
||||
|
||||
Примечание: Декоратор помечает функцию как скрытую от API.
|
||||
Эндпоинт все еще будет работать, но не будет отображаться в Swagger UI.
|
||||
Декоратор должен быть применен ПЕРЕД декоратором route (снизу вверх).
|
||||
|
||||
Args:
|
||||
func: Функция для декорирования.
|
||||
|
||||
Returns:
|
||||
Декорированная функция с пометкой о скрытии от API.
|
||||
"""
|
||||
# Помечаем функцию, что она должна быть скрыта от API
|
||||
func.__hide_from_api__ = True
|
||||
|
||||
# Просто возвращаем функцию без изменений
|
||||
# Скрытие из API выполняется в custom_openapi
|
||||
return func
|
||||
127
app/core/button_utils.py
Normal file
127
app/core/button_utils.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Утилиты для работы с кнопками в различных мессенджерах.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def convert_telegram_buttons_to_dict(buttons: Optional[InlineKeyboardMarkup]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Преобразовать кнопки Telegram (InlineKeyboardMarkup) в универсальный формат Dict.
|
||||
|
||||
Args:
|
||||
buttons: InlineKeyboardMarkup с кнопками или None.
|
||||
|
||||
Returns:
|
||||
Словарь с кнопками в универсальном формате или None.
|
||||
Формат:
|
||||
{
|
||||
"inline_keyboard": [
|
||||
[
|
||||
{"text": "Кнопка 1", "url": "https://example.com"},
|
||||
{"text": "Кнопка 2", "url": "https://example2.com"}
|
||||
],
|
||||
[
|
||||
{"text": "Кнопка 3", "url": "https://example3.com"}
|
||||
]
|
||||
]
|
||||
}
|
||||
"""
|
||||
if buttons is None:
|
||||
return None
|
||||
|
||||
if not isinstance(buttons, InlineKeyboardMarkup):
|
||||
logger.warning(f"Неизвестный тип кнопок: {type(buttons)}")
|
||||
return None
|
||||
|
||||
inline_keyboard = []
|
||||
for row in buttons.inline_keyboard:
|
||||
row_buttons = []
|
||||
for button in row:
|
||||
if isinstance(button, InlineKeyboardButton):
|
||||
button_dict = {"text": button.text}
|
||||
if button.url:
|
||||
button_dict["url"] = button.url
|
||||
if button.callback_data:
|
||||
button_dict["callback_data"] = button.callback_data
|
||||
row_buttons.append(button_dict)
|
||||
if row_buttons:
|
||||
inline_keyboard.append(row_buttons)
|
||||
|
||||
if not inline_keyboard:
|
||||
return None
|
||||
|
||||
return {"inline_keyboard": inline_keyboard}
|
||||
|
||||
|
||||
def convert_dict_to_telegram_buttons(buttons_dict: Optional[Dict[str, Any]]) -> Optional[InlineKeyboardMarkup]:
|
||||
"""
|
||||
Преобразовать универсальный формат Dict в кнопки Telegram (InlineKeyboardMarkup).
|
||||
|
||||
Args:
|
||||
buttons_dict: Словарь с кнопками в универсальном формате или None.
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup с кнопками или None.
|
||||
"""
|
||||
if buttons_dict is None:
|
||||
return None
|
||||
|
||||
inline_keyboard_data = buttons_dict.get("inline_keyboard", [])
|
||||
if not inline_keyboard_data:
|
||||
return None
|
||||
|
||||
inline_keyboard = []
|
||||
for row in inline_keyboard_data:
|
||||
row_buttons = []
|
||||
for button_data in row:
|
||||
if isinstance(button_data, dict):
|
||||
text = button_data.get("text")
|
||||
url = button_data.get("url")
|
||||
callback_data = button_data.get("callback_data")
|
||||
|
||||
if text:
|
||||
if url:
|
||||
row_buttons.append(InlineKeyboardButton(text=text, url=url))
|
||||
elif callback_data:
|
||||
row_buttons.append(InlineKeyboardButton(text=text, callback_data=callback_data))
|
||||
if row_buttons:
|
||||
inline_keyboard.append(row_buttons)
|
||||
|
||||
if not inline_keyboard:
|
||||
return None
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard)
|
||||
|
||||
|
||||
def convert_dict_to_vk_buttons(buttons_dict: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
"""
|
||||
Преобразовать универсальный формат Dict в кнопки VK (JSON строка).
|
||||
|
||||
Args:
|
||||
buttons_dict: Словарь с кнопками в универсальном формате или None.
|
||||
|
||||
Returns:
|
||||
JSON строка с кнопками для VK API или None.
|
||||
"""
|
||||
if buttons_dict is None:
|
||||
return None
|
||||
|
||||
inline_keyboard_data = buttons_dict.get("inline_keyboard", [])
|
||||
if not inline_keyboard_data:
|
||||
return None
|
||||
|
||||
# Формируем клавиатуру для VK API
|
||||
# VK API использует другой формат клавиатуры
|
||||
# Для простоты возвращаем None, так как VK API требует более сложной структуры
|
||||
# В реальной реализации нужно преобразовать в формат VK Keyboard
|
||||
|
||||
logger.warning("Преобразование кнопок в формат VK пока не реализовано полностью")
|
||||
return None
|
||||
|
||||
132
app/core/config.py
Normal file
132
app/core/config.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Конфигурация приложения.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Настройки приложения из переменных окружения."""
|
||||
|
||||
# Telegram настройки
|
||||
telegram_bot_token: Optional[str] = None
|
||||
telegram_enabled: bool = True
|
||||
|
||||
# MAX/VK настройки
|
||||
max_access_token: Optional[str] = None
|
||||
max_api_version: str = "5.131"
|
||||
max_enabled: bool = False
|
||||
|
||||
# Общие настройки мессенджеров
|
||||
default_messenger: str = "telegram" # По умолчанию Telegram
|
||||
|
||||
# Файлы конфигурации
|
||||
groups_config_path: str = "/app/config/groups.json"
|
||||
templates_path: str = "/app/templates"
|
||||
|
||||
# API ключ для авторизации
|
||||
api_key: Optional[str] = None
|
||||
|
||||
# Grafana настройки
|
||||
grafana_url: Optional[str] = None
|
||||
|
||||
# Zabbix настройки
|
||||
zabbix_url: Optional[str] = None
|
||||
|
||||
# Kubernetes кластер настройки
|
||||
k8s_cluster_grafana_subdomain: Optional[str] = None
|
||||
k8s_cluster_prometheus_subdomain: Optional[str] = None
|
||||
k8s_cluster_alertmanager_subdomain: Optional[str] = None
|
||||
|
||||
# Prometheus Pushgateway настройки
|
||||
pushgateway_url: Optional[str] = None
|
||||
pushgateway_job: str = "MessageGateway"
|
||||
|
||||
# OpenTelemetry настройки
|
||||
otel_enabled: bool = False
|
||||
otel_service_name: str = "monitoring-message-gateway"
|
||||
otel_exporter_otlp_endpoint: Optional[str] = None
|
||||
otel_exporter_otlp_protocol: str = "http/json"
|
||||
otel_traces_exporter: str = "otlp_proto_http"
|
||||
otel_exporter_otlp_insecure: bool = True
|
||||
otel_python_log_correlation: bool = False
|
||||
|
||||
# Jira настройки
|
||||
jira_enabled: bool = False
|
||||
jira_url: Optional[str] = None
|
||||
jira_email: Optional[str] = None
|
||||
jira_api_token: Optional[str] = None
|
||||
jira_project_key: Optional[str] = None
|
||||
jira_default_assignee: Optional[str] = None
|
||||
jira_default_issue_type: str = "Bug"
|
||||
jira_mapping_config_path: str = "/app/config/jira_mapping.json"
|
||||
jira_create_on_alert: bool = True
|
||||
jira_create_on_resolved: bool = False
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
env_ignore_empty=True
|
||||
)
|
||||
|
||||
def validate_required(self) -> None:
|
||||
"""Проверка обязательных переменных окружения."""
|
||||
if not self.telegram_bot_token:
|
||||
logger.warning("TELEGRAM_BOT_TOKEN не установлен - приложение может не работать")
|
||||
# Не выбрасываем исключение, чтобы приложение могло запуститься
|
||||
|
||||
def get_k8s_grafana_url(self, cluster: str) -> str:
|
||||
"""Получить URL Grafana для Kubernetes кластера."""
|
||||
if not self.k8s_cluster_grafana_subdomain:
|
||||
raise ValueError("K8S_CLUSTER_GRAFANA_SUBDOMAIN не установлен")
|
||||
return f"{cluster}.{self.k8s_cluster_grafana_subdomain}"
|
||||
|
||||
def get_k8s_prometheus_url(self, cluster: str) -> str:
|
||||
"""Получить URL Prometheus для Kubernetes кластера."""
|
||||
if not self.k8s_cluster_prometheus_subdomain:
|
||||
raise ValueError("K8S_CLUSTER_PROMETHEUS_SUBDOMAIN не установлен")
|
||||
return f"{cluster}.{self.k8s_cluster_prometheus_subdomain}"
|
||||
|
||||
def get_k8s_alertmanager_url(self, cluster: str) -> str:
|
||||
"""Получить URL AlertManager для Kubernetes кластера."""
|
||||
if not self.k8s_cluster_alertmanager_subdomain:
|
||||
raise ValueError("K8S_CLUSTER_ALERTMANAGER_SUBDOMAIN не установлен")
|
||||
return f"{cluster}.{self.k8s_cluster_alertmanager_subdomain}"
|
||||
|
||||
|
||||
# Глобальный экземпляр настроек (валидация отложена до первого использования)
|
||||
_settings_instance: Optional[Settings] = None
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""
|
||||
Получить экземпляр настроек (lazy initialization).
|
||||
|
||||
Returns:
|
||||
Экземпляр Settings.
|
||||
"""
|
||||
global _settings_instance
|
||||
if _settings_instance is None:
|
||||
_settings_instance = Settings()
|
||||
return _settings_instance
|
||||
|
||||
|
||||
# Глобальный экземпляр настроек (lazy initialization)
|
||||
class _SettingsProxy:
|
||||
"""Прокси для ленивой инициализации settings."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Получить атрибут из settings."""
|
||||
return getattr(get_settings(), name)
|
||||
|
||||
|
||||
settings = _SettingsProxy()
|
||||
370
app/core/groups.py
Normal file
370
app/core/groups.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
Управление конфигурацией групп для различных мессенджеров.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Optional, Any, Union
|
||||
import aiofiles
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GroupsConfig:
|
||||
"""Менеджер конфигурации групп для различных мессенджеров с кэшированием."""
|
||||
|
||||
def __init__(self, config_path: Optional[str] = None):
|
||||
"""
|
||||
Инициализация менеджера конфигурации.
|
||||
|
||||
Args:
|
||||
config_path: Путь к файлу конфигурации групп.
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
self.config_path = config_path or settings.groups_config_path
|
||||
self._cache: Optional[Dict[str, Any]] = None
|
||||
self._cache_time: Optional[datetime] = None
|
||||
self._cache_ttl = timedelta(minutes=5) # Кэш на 5 минут
|
||||
self.default_messenger = settings.default_messenger
|
||||
|
||||
|
||||
async def _load_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Загрузить конфигурацию групп из файла.
|
||||
|
||||
Returns:
|
||||
Конфигурация групп в формате:
|
||||
{
|
||||
"group_name": {
|
||||
"messenger": "telegram",
|
||||
"chat_id": -1001997464975,
|
||||
"thread_id": 0,
|
||||
"config": {}
|
||||
}
|
||||
}
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Если файл конфигурации не найден.
|
||||
json.JSONDecodeError: Если файл содержит некорректный JSON.
|
||||
ValueError: Если конфигурация имеет неверный формат.
|
||||
"""
|
||||
try:
|
||||
async with aiofiles.open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
config = json.loads(content)
|
||||
logger.info(f"Конфигурация групп загружена из {self.config_path}")
|
||||
|
||||
# Валидация формата конфигурации
|
||||
for group_name, group_value in config.items():
|
||||
if not isinstance(group_value, dict):
|
||||
raise ValueError(
|
||||
f"Неверный формат конфигурации для группы '{group_name}': "
|
||||
f"ожидается словарь, получен {type(group_value)}. "
|
||||
f"Используйте формат: {{'messenger': 'telegram', 'chat_id': ..., 'thread_id': 0, 'config': {{}}}}"
|
||||
)
|
||||
if "chat_id" not in group_value:
|
||||
raise ValueError(
|
||||
f"Отсутствует обязательное поле 'chat_id' для группы '{group_name}'"
|
||||
)
|
||||
if "messenger" not in group_value:
|
||||
raise ValueError(
|
||||
f"Отсутствует обязательное поле 'messenger' для группы '{group_name}'"
|
||||
)
|
||||
|
||||
return config
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Файл конфигурации групп не найден: {self.config_path}")
|
||||
raise
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Ошибка парсинга JSON в файле конфигурации: {e}")
|
||||
raise
|
||||
|
||||
def _is_cache_valid(self) -> bool:
|
||||
"""Проверить, валиден ли кэш."""
|
||||
if self._cache is None or self._cache_time is None:
|
||||
return False
|
||||
return datetime.now() - self._cache_time < self._cache_ttl
|
||||
|
||||
async def get_group_config(self, group_name: str, messenger: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Получить конфигурацию группы.
|
||||
|
||||
Args:
|
||||
group_name: Имя группы из конфигурации.
|
||||
messenger: Тип мессенджера (опционально, для фильтрации).
|
||||
|
||||
Returns:
|
||||
Конфигурация группы или None, если группа не найдена.
|
||||
Формат:
|
||||
{
|
||||
"messenger": "telegram",
|
||||
"chat_id": -1001997464975,
|
||||
"thread_id": 0,
|
||||
"config": {}
|
||||
}
|
||||
"""
|
||||
# Проверяем кэш
|
||||
if not self._is_cache_valid():
|
||||
try:
|
||||
self._cache = await self._load_config()
|
||||
self._cache_time = datetime.now()
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
logger.error("Не удалось загрузить конфигурацию групп")
|
||||
return None
|
||||
|
||||
group_config = self._cache.get(group_name)
|
||||
if group_config is None:
|
||||
logger.warning(f"Группа '{group_name}' не найдена в конфигурации")
|
||||
return None
|
||||
|
||||
# Если указан messenger, проверяем соответствие
|
||||
if messenger and group_config.get("messenger") != messenger:
|
||||
logger.warning(
|
||||
f"Группа '{group_name}' имеет мессенджер '{group_config.get('messenger')}', "
|
||||
f"но запрошен '{messenger}'"
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(f"Найдена конфигурация для группы '{group_name}': {group_config}")
|
||||
return group_config
|
||||
|
||||
async def get_chat_id(self, group_name: str, messenger: Optional[str] = None) -> Optional[Union[int, str]]:
|
||||
"""
|
||||
Получить ID чата по имени группы.
|
||||
|
||||
Args:
|
||||
group_name: Имя группы из конфигурации.
|
||||
messenger: Тип мессенджера (опционально).
|
||||
|
||||
Returns:
|
||||
ID чата или None, если группа не найдена.
|
||||
"""
|
||||
group_config = await self.get_group_config(group_name, messenger)
|
||||
if group_config is None:
|
||||
return None
|
||||
return group_config.get("chat_id")
|
||||
|
||||
async def refresh_cache(self) -> None:
|
||||
"""Принудительно обновить кэш конфигурации."""
|
||||
try:
|
||||
self._cache = await self._load_config()
|
||||
self._cache_time = datetime.now()
|
||||
logger.info("Кэш конфигурации групп обновлен")
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
logger.error(f"Ошибка обновления кэша: {e}")
|
||||
|
||||
async def _save_config(self, config: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Сохранить конфигурацию групп в файл.
|
||||
|
||||
Args:
|
||||
config: Нормализованная конфигурация групп в новом формате.
|
||||
|
||||
Raises:
|
||||
IOError: Если не удалось записать файл.
|
||||
"""
|
||||
try:
|
||||
async with aiofiles.open(self.config_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(json.dumps(config, indent=2, ensure_ascii=False))
|
||||
logger.info(f"Конфигурация групп сохранена в {self.config_path}")
|
||||
# Обновляем кэш
|
||||
self._cache = config
|
||||
self._cache_time = datetime.now()
|
||||
except IOError as e:
|
||||
logger.error(f"Ошибка записи конфигурации групп: {e}")
|
||||
raise
|
||||
|
||||
async def get_all_groups(self, include_ids: bool = False, messenger: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Получить все группы из конфигурации.
|
||||
|
||||
Args:
|
||||
include_ids: Включать ли полную конфигурацию групп (включая ID, мессенджер и т.д.).
|
||||
messenger: Фильтр по типу мессенджера (опционально).
|
||||
|
||||
Returns:
|
||||
Словарь с группами.
|
||||
Если include_ids=False, возвращает только названия групп.
|
||||
Если include_ids=True, возвращает полную конфигурацию групп.
|
||||
"""
|
||||
if not self._is_cache_valid():
|
||||
try:
|
||||
self._cache = await self._load_config()
|
||||
self._cache_time = datetime.now()
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
logger.error("Не удалось загрузить конфигурацию групп")
|
||||
return {}
|
||||
|
||||
# Фильтруем по мессенджеру, если указан
|
||||
filtered_config = self._cache.copy()
|
||||
if messenger:
|
||||
filtered_config = {
|
||||
name: config
|
||||
for name, config in filtered_config.items()
|
||||
if config.get("messenger") == messenger
|
||||
}
|
||||
|
||||
if include_ids:
|
||||
return filtered_config.copy()
|
||||
else:
|
||||
# Возвращаем только названия групп без конфигурации
|
||||
return {name: None for name in filtered_config.keys()}
|
||||
|
||||
async def create_group(
|
||||
self,
|
||||
group_name: str,
|
||||
chat_id: Union[int, str],
|
||||
messenger: str = "telegram",
|
||||
thread_id: int = 0,
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Создать новую группу в конфигурации.
|
||||
|
||||
Args:
|
||||
group_name: Имя группы.
|
||||
chat_id: ID чата (может быть int или str).
|
||||
messenger: Тип мессенджера (telegram, max).
|
||||
thread_id: ID треда в группе (по умолчанию 0).
|
||||
config: Дополнительная конфигурация для мессенджера (опционально).
|
||||
|
||||
Returns:
|
||||
True если группа создана успешно, False если группа уже существует.
|
||||
"""
|
||||
# Загружаем текущую конфигурацию
|
||||
if not self._is_cache_valid():
|
||||
try:
|
||||
self._cache = await self._load_config()
|
||||
self._cache_time = datetime.now()
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
# Если файл не существует, создаем новый
|
||||
self._cache = {}
|
||||
|
||||
# Проверяем, существует ли группа
|
||||
if group_name in self._cache:
|
||||
logger.warning(f"Группа '{group_name}' уже существует")
|
||||
return False
|
||||
|
||||
# Добавляем группу
|
||||
self._cache[group_name] = {
|
||||
"messenger": messenger,
|
||||
"chat_id": chat_id,
|
||||
"thread_id": thread_id,
|
||||
"config": config or {}
|
||||
}
|
||||
await self._save_config(self._cache)
|
||||
logger.info(f"Группа '{group_name}' создана с мессенджером '{messenger}' и ID {chat_id}")
|
||||
return True
|
||||
|
||||
async def update_group(
|
||||
self,
|
||||
group_name: str,
|
||||
chat_id: Optional[Union[int, str]] = None,
|
||||
messenger: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Обновить существующую группу в конфигурации.
|
||||
|
||||
Args:
|
||||
group_name: Имя группы.
|
||||
chat_id: Новый ID чата (опционально).
|
||||
messenger: Новый тип мессенджера (опционально).
|
||||
thread_id: Новый ID треда (опционально).
|
||||
config: Новая дополнительная конфигурация (опционально).
|
||||
|
||||
Returns:
|
||||
True если группа обновлена успешно, False если группа не найдена.
|
||||
"""
|
||||
# Загружаем текущую конфигурацию
|
||||
if not self._is_cache_valid():
|
||||
try:
|
||||
self._cache = await self._load_config()
|
||||
self._cache_time = datetime.now()
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
logger.error("Не удалось загрузить конфигурацию групп")
|
||||
return False
|
||||
|
||||
# Проверяем, существует ли группа
|
||||
if group_name not in self._cache:
|
||||
logger.warning(f"Группа '{group_name}' не найдена")
|
||||
return False
|
||||
|
||||
# Обновляем группу (обновляем только указанные поля)
|
||||
old_config = self._cache[group_name].copy()
|
||||
if chat_id is not None:
|
||||
self._cache[group_name]["chat_id"] = chat_id
|
||||
if messenger is not None:
|
||||
self._cache[group_name]["messenger"] = messenger
|
||||
if thread_id is not None:
|
||||
self._cache[group_name]["thread_id"] = thread_id
|
||||
if config is not None:
|
||||
self._cache[group_name]["config"] = config
|
||||
|
||||
await self._save_config(self._cache)
|
||||
logger.info(f"Группа '{group_name}' обновлена: {old_config} -> {self._cache[group_name]}")
|
||||
return True
|
||||
|
||||
async def delete_group(self, group_name: str) -> bool:
|
||||
"""
|
||||
Удалить группу из конфигурации.
|
||||
|
||||
Args:
|
||||
group_name: Имя группы.
|
||||
|
||||
Returns:
|
||||
True если группа удалена успешно, False если группа не найдена.
|
||||
"""
|
||||
# Загружаем текущую конфигурацию
|
||||
if not self._is_cache_valid():
|
||||
try:
|
||||
self._cache = await self._load_config()
|
||||
self._cache_time = datetime.now()
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
logger.error("Не удалось загрузить конфигурацию групп")
|
||||
return False
|
||||
|
||||
# Проверяем, существует ли группа
|
||||
if group_name not in self._cache:
|
||||
logger.warning(f"Группа '{group_name}' не найдена")
|
||||
return False
|
||||
|
||||
# Удаляем группу
|
||||
del self._cache[group_name]
|
||||
await self._save_config(self._cache)
|
||||
logger.info(f"Группа '{group_name}' удалена")
|
||||
return True
|
||||
|
||||
|
||||
# Глобальный экземпляр менеджера конфигурации (lazy initialization)
|
||||
_groups_config_instance = None
|
||||
|
||||
|
||||
def get_groups_config() -> GroupsConfig:
|
||||
"""
|
||||
Получить экземпляр менеджера конфигурации групп (lazy initialization).
|
||||
|
||||
Returns:
|
||||
Экземпляр GroupsConfig.
|
||||
"""
|
||||
global _groups_config_instance
|
||||
if _groups_config_instance is None:
|
||||
_groups_config_instance = GroupsConfig()
|
||||
return _groups_config_instance
|
||||
|
||||
|
||||
# Глобальный экземпляр менеджера конфигурации (lazy initialization)
|
||||
class _GroupsConfigProxy:
|
||||
"""Прокси для ленивой инициализации groups_config."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Получить атрибут из groups_config."""
|
||||
return getattr(get_groups_config(), name)
|
||||
|
||||
|
||||
groups_config = _GroupsConfigProxy()
|
||||
236
app/core/jira_client.py
Normal file
236
app/core/jira_client.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
Клиент для работы с Jira API.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from jira import JIRA
|
||||
from jira.exceptions import JIRAError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JiraClient:
|
||||
"""Клиент для работы с Jira API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
email: str,
|
||||
api_token: str
|
||||
):
|
||||
"""
|
||||
Инициализация клиента Jira.
|
||||
|
||||
Args:
|
||||
url: URL Jira сервера.
|
||||
email: Email пользователя Jira.
|
||||
api_token: API токен Jira.
|
||||
"""
|
||||
self.url = url.rstrip('/')
|
||||
self.email = email
|
||||
self.api_token = api_token
|
||||
self._client: Optional[JIRA] = None
|
||||
|
||||
def get_client(self) -> JIRA:
|
||||
"""
|
||||
Получить экземпляр клиента Jira (создается при первом обращении).
|
||||
|
||||
Returns:
|
||||
Экземпляр JIRA клиента.
|
||||
"""
|
||||
if self._client is None:
|
||||
try:
|
||||
self._client = JIRA(
|
||||
server=self.url,
|
||||
basic_auth=(self.email, self.api_token)
|
||||
)
|
||||
logger.info(f"Jira клиент подключен к {self.url}")
|
||||
except JIRAError as e:
|
||||
logger.error(f"Ошибка подключения к Jira: {e}")
|
||||
raise
|
||||
return self._client
|
||||
|
||||
def create_issue(
|
||||
self,
|
||||
project: str,
|
||||
summary: str,
|
||||
description: str,
|
||||
issue_type: str = "Bug",
|
||||
assignee: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
labels: Optional[list] = None,
|
||||
components: Optional[list] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Создать тикет в Jira.
|
||||
|
||||
Args:
|
||||
project: Ключ проекта Jira.
|
||||
summary: Заголовок тикета.
|
||||
description: Описание тикета.
|
||||
issue_type: Тип задачи.
|
||||
assignee: Email исполнителя (опционально).
|
||||
priority: Приоритет задачи (опционально).
|
||||
labels: Список меток (опционально).
|
||||
components: Список компонентов (опционально).
|
||||
|
||||
Returns:
|
||||
Ключ созданного тикета (например, "MON-123") или None в случае ошибки.
|
||||
"""
|
||||
try:
|
||||
client = self.get_client()
|
||||
|
||||
# Формируем словарь для создания тикета
|
||||
issue_dict = {
|
||||
'project': {'key': project},
|
||||
'summary': summary,
|
||||
'description': description,
|
||||
'issuetype': {'name': issue_type}
|
||||
}
|
||||
|
||||
# Добавляем приоритет, если указан
|
||||
if priority:
|
||||
issue_dict['priority'] = {'name': priority}
|
||||
|
||||
# Добавляем метки, если указаны
|
||||
if labels:
|
||||
issue_dict['labels'] = labels
|
||||
|
||||
# Добавляем компоненты, если указаны
|
||||
if components:
|
||||
issue_dict['components'] = [{'name': comp} for comp in components]
|
||||
|
||||
# Создаем тикет
|
||||
issue = client.create_issue(fields=issue_dict)
|
||||
|
||||
# Назначаем исполнителя, если указан
|
||||
if assignee:
|
||||
try:
|
||||
# Пытаемся найти пользователя по email или username
|
||||
users = client.search_users(query=assignee)
|
||||
if users:
|
||||
# Назначаем первого найденного пользователя
|
||||
user_account_id = users[0].accountId
|
||||
client.assign_issue(issue, user_account_id)
|
||||
logger.info(f"Исполнитель {assignee} (accountId: {user_account_id}) назначен на тикет {issue.key}")
|
||||
else:
|
||||
logger.warning(f"Пользователь {assignee} не найден в Jira, тикет создан без исполнителя")
|
||||
except JIRAError as e:
|
||||
logger.error(f"Ошибка назначения исполнителя {assignee}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при назначении исполнителя: {e}")
|
||||
|
||||
logger.info(f"Тикет {issue.key} создан в Jira")
|
||||
return issue.key
|
||||
|
||||
except JIRAError as e:
|
||||
logger.error(f"Ошибка создания тикета в Jira: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при создании тикета: {e}")
|
||||
return None
|
||||
|
||||
def get_issue_url(self, issue_key: str) -> str:
|
||||
"""
|
||||
Получить URL тикета в Jira.
|
||||
|
||||
Args:
|
||||
issue_key: Ключ тикета (например, "MON-123").
|
||||
|
||||
Returns:
|
||||
URL тикета в Jira.
|
||||
"""
|
||||
return f"{self.url}/browse/{issue_key}"
|
||||
|
||||
def update_issue(
|
||||
self,
|
||||
issue_key: str,
|
||||
summary: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
assignee: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
labels: Optional[list] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Обновить тикет в Jira.
|
||||
|
||||
Args:
|
||||
issue_key: Ключ тикета.
|
||||
summary: Новый заголовок (опционально).
|
||||
description: Новое описание (опционально).
|
||||
assignee: Новый исполнитель (опционально).
|
||||
priority: Новый приоритет (опционально).
|
||||
labels: Новые метки (опционально).
|
||||
|
||||
Returns:
|
||||
True если тикет обновлен успешно, False в противном случае.
|
||||
"""
|
||||
try:
|
||||
client = self.get_client()
|
||||
issue = client.issue(issue_key)
|
||||
|
||||
update_dict = {}
|
||||
|
||||
if summary:
|
||||
update_dict['summary'] = [{'set': summary}]
|
||||
if description:
|
||||
update_dict['description'] = [{'set': description}]
|
||||
if priority:
|
||||
update_dict['priority'] = [{'set': {'name': priority}}]
|
||||
if labels:
|
||||
update_dict['labels'] = [{'set': labels}]
|
||||
|
||||
if update_dict:
|
||||
issue.update(fields=update_dict)
|
||||
|
||||
if assignee:
|
||||
try:
|
||||
users = client.search_users(query=assignee)
|
||||
if users:
|
||||
user_account_id = users[0].accountId
|
||||
client.assign_issue(issue, user_account_id)
|
||||
logger.info(f"Исполнитель {assignee} (accountId: {user_account_id}) назначен на тикет {issue_key}")
|
||||
else:
|
||||
logger.warning(f"Пользователь {assignee} не найден в Jira")
|
||||
except JIRAError as e:
|
||||
logger.error(f"Ошибка назначения исполнителя {assignee}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при назначении исполнителя: {e}")
|
||||
|
||||
logger.info(f"Тикет {issue_key} обновлен в Jira")
|
||||
return True
|
||||
|
||||
except JIRAError as e:
|
||||
logger.error(f"Ошибка обновления тикета {issue_key}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при обновлении тикета: {e}")
|
||||
return False
|
||||
|
||||
def add_comment(self, issue_key: str, comment: str) -> bool:
|
||||
"""
|
||||
Добавить комментарий к тикету.
|
||||
|
||||
Args:
|
||||
issue_key: Ключ тикета.
|
||||
comment: Текст комментария.
|
||||
|
||||
Returns:
|
||||
True если комментарий добавлен успешно, False в противном случае.
|
||||
"""
|
||||
try:
|
||||
client = self.get_client()
|
||||
issue = client.issue(issue_key)
|
||||
issue.add_comment(comment)
|
||||
logger.info(f"Комментарий добавлен к тикету {issue_key}")
|
||||
return True
|
||||
except JIRAError as e:
|
||||
logger.error(f"Ошибка добавления комментария к тикету {issue_key}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при добавлении комментария: {e}")
|
||||
return False
|
||||
|
||||
212
app/core/jira_mapping.py
Normal file
212
app/core/jira_mapping.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Управление конфигурацией маппинга алертов в Jira тикеты.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
import aiofiles
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.models.jira import JiraMappingConfig, JiraSourceMapping, JiraMapping, JiraMappingCondition
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JiraMappingManager:
|
||||
"""Менеджер конфигурации маппинга алертов в Jira тикеты с кэшированием."""
|
||||
|
||||
def __init__(self, config_path: Optional[str] = None):
|
||||
"""
|
||||
Инициализация менеджера конфигурации.
|
||||
|
||||
Args:
|
||||
config_path: Путь к файлу конфигурации маппинга.
|
||||
"""
|
||||
settings = get_settings()
|
||||
self.config_path = config_path or settings.jira_mapping_config_path
|
||||
self._cache: Optional[JiraMappingConfig] = None
|
||||
self._cache_time: Optional[datetime] = None
|
||||
self._cache_ttl = timedelta(minutes=10) # Кэш на 10 минут
|
||||
|
||||
async def _load_config(self) -> JiraMappingConfig:
|
||||
"""
|
||||
Загрузить конфигурацию маппинга из файла.
|
||||
|
||||
Returns:
|
||||
Конфигурация маппинга алертов в Jira тикеты.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Если файл конфигурации не найден.
|
||||
json.JSONDecodeError: Если файл содержит некорректный JSON.
|
||||
"""
|
||||
try:
|
||||
async with aiofiles.open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
config_dict = json.loads(content)
|
||||
config = JiraMappingConfig(**config_dict)
|
||||
logger.info(f"Конфигурация маппинга Jira загружена из {self.config_path}")
|
||||
return config
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Файл конфигурации маппинга Jira не найден: {self.config_path}")
|
||||
# Возвращаем пустую конфигурацию
|
||||
return JiraMappingConfig()
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Ошибка парсинга JSON в файле конфигурации маппинга: {e}")
|
||||
return JiraMappingConfig()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки конфигурации маппинга: {e}")
|
||||
return JiraMappingConfig()
|
||||
|
||||
def _is_cache_valid(self) -> bool:
|
||||
"""Проверить, валиден ли кэш."""
|
||||
if self._cache is None or self._cache_time is None:
|
||||
return False
|
||||
return datetime.now() - self._cache_time < self._cache_ttl
|
||||
|
||||
async def get_config(self) -> JiraMappingConfig:
|
||||
"""
|
||||
Получить конфигурацию маппинга (с кэшированием).
|
||||
|
||||
Returns:
|
||||
Конфигурация маппинга алертов в Jira тикеты.
|
||||
"""
|
||||
if not self._is_cache_valid():
|
||||
self._cache = await self._load_config()
|
||||
self._cache_time = datetime.now()
|
||||
return self._cache
|
||||
|
||||
async def find_mapping(
|
||||
self,
|
||||
source: str,
|
||||
alert_data: Dict[str, Any]
|
||||
) -> Optional[JiraMapping]:
|
||||
"""
|
||||
Найти подходящий маппинг для алерта.
|
||||
|
||||
Args:
|
||||
source: Источник алерта (alertmanager, grafana, zabbix).
|
||||
alert_data: Данные алерта.
|
||||
|
||||
Returns:
|
||||
Подходящий маппинг или None, если маппинг не найден.
|
||||
"""
|
||||
config = await self.get_config()
|
||||
|
||||
# Получаем конфигурацию для источника
|
||||
source_mapping: Optional[JiraSourceMapping] = None
|
||||
if source == "alertmanager" and config.alertmanager:
|
||||
source_mapping = config.alertmanager
|
||||
elif source == "grafana" and config.grafana:
|
||||
source_mapping = config.grafana
|
||||
elif source == "zabbix" and config.zabbix:
|
||||
source_mapping = config.zabbix
|
||||
|
||||
if not source_mapping:
|
||||
return None
|
||||
|
||||
# Ищем подходящий маппинг по условиям
|
||||
for mapping in source_mapping.mappings:
|
||||
if self._check_conditions(mapping.conditions, alert_data):
|
||||
return mapping
|
||||
|
||||
# Если маппинг не найден, возвращаем дефолтный маппинг
|
||||
return JiraMapping(
|
||||
conditions=JiraMappingCondition(),
|
||||
project=source_mapping.default_project,
|
||||
assignee=source_mapping.default_assignee,
|
||||
issue_type=source_mapping.default_issue_type,
|
||||
priority=source_mapping.default_priority,
|
||||
labels=[]
|
||||
)
|
||||
|
||||
def _check_conditions(
|
||||
self,
|
||||
conditions: JiraMappingCondition,
|
||||
alert_data: Dict[str, Any]
|
||||
) -> bool:
|
||||
"""
|
||||
Проверить, соответствуют ли данные алерта условиям маппинга.
|
||||
|
||||
Args:
|
||||
conditions: Условия маппинга.
|
||||
alert_data: Данные алерта.
|
||||
|
||||
Returns:
|
||||
True если условия выполнены, False в противном случае.
|
||||
"""
|
||||
# Проверяем severity
|
||||
if conditions.severity:
|
||||
if alert_data.get("severity") != conditions.severity:
|
||||
return False
|
||||
|
||||
# Проверяем namespace
|
||||
if conditions.namespace:
|
||||
if alert_data.get("namespace") != conditions.namespace:
|
||||
return False
|
||||
|
||||
# Проверяем state
|
||||
if conditions.state:
|
||||
if alert_data.get("state") != conditions.state:
|
||||
return False
|
||||
|
||||
# Проверяем status
|
||||
if conditions.status:
|
||||
if alert_data.get("status") != conditions.status:
|
||||
return False
|
||||
|
||||
# Проверяем event-severity
|
||||
if conditions.event_severity:
|
||||
if alert_data.get("event-severity") != conditions.event_severity:
|
||||
return False
|
||||
|
||||
# Проверяем теги
|
||||
if conditions.tags:
|
||||
alert_tags = alert_data.get("tags", {})
|
||||
for key, value in conditions.tags.items():
|
||||
if alert_tags.get(key) != value:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def refresh_cache(self) -> None:
|
||||
"""Принудительно обновить кэш конфигурации."""
|
||||
try:
|
||||
self._cache = await self._load_config()
|
||||
self._cache_time = datetime.now()
|
||||
logger.info("Кэш конфигурации маппинга Jira обновлен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления кэша: {e}")
|
||||
|
||||
|
||||
# Глобальный экземпляр менеджера конфигурации маппинга (lazy initialization)
|
||||
_jira_mapping_manager_instance = None
|
||||
|
||||
|
||||
def get_jira_mapping_manager() -> JiraMappingManager:
|
||||
"""
|
||||
Получить экземпляр менеджера конфигурации маппинга Jira (lazy initialization).
|
||||
|
||||
Returns:
|
||||
Экземпляр JiraMappingManager.
|
||||
"""
|
||||
global _jira_mapping_manager_instance
|
||||
if _jira_mapping_manager_instance is None:
|
||||
_jira_mapping_manager_instance = JiraMappingManager()
|
||||
return _jira_mapping_manager_instance
|
||||
|
||||
|
||||
# Для обратной совместимости (lazy initialization)
|
||||
class _JiraMappingManagerProxy:
|
||||
"""Прокси для ленивой инициализации jira_mapping_manager."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Получить атрибут из jira_mapping_manager."""
|
||||
return getattr(get_jira_mapping_manager(), name)
|
||||
|
||||
|
||||
jira_mapping_manager = _JiraMappingManagerProxy()
|
||||
|
||||
330
app/core/jira_utils.py
Normal file
330
app/core/jira_utils.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
Утилиты для работы с Jira тикетами.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.models.alertmanager import PrometheusAlert
|
||||
from app.models.grafana import GrafanaAlert
|
||||
from app.models.zabbix import ZabbixAlert
|
||||
from app.models.jira import JiraMapping
|
||||
from app.core.jira_client import JiraClient
|
||||
from app.core.jira_mapping import jira_mapping_manager
|
||||
from app.core.config import get_settings
|
||||
from app.core.utils import add_spaces_to_alert_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_jira_ticket_from_alert(
|
||||
alert: Any,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
mapping: Optional[JiraMapping] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Создать Jira тикет на основе алерта.
|
||||
|
||||
Args:
|
||||
alert: Данные алерта (PrometheusAlert, GrafanaAlert, ZabbixAlert).
|
||||
source: Источник алерта (alertmanager, grafana, zabbix).
|
||||
k8s_cluster: Имя Kubernetes кластера (опционально).
|
||||
mapping: Маппинг для создания тикета (опционально).
|
||||
|
||||
Returns:
|
||||
Ключ созданного тикета или None в случае ошибки.
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.jira_enabled:
|
||||
logger.debug("Jira отключен, тикет не создается")
|
||||
return None
|
||||
|
||||
if not settings.jira_url or not settings.jira_email or not settings.jira_api_token:
|
||||
logger.warning("Jira не настроен (отсутствуют URL, email или API token)")
|
||||
return None
|
||||
|
||||
# Создаем клиент Jira
|
||||
try:
|
||||
jira_client = JiraClient(
|
||||
url=settings.jira_url,
|
||||
email=settings.jira_email,
|
||||
api_token=settings.jira_api_token
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Jira клиента: {e}")
|
||||
return None
|
||||
|
||||
# Получаем маппинг, если не указан
|
||||
if not mapping:
|
||||
alert_data = _extract_alert_data(alert, source, k8s_cluster)
|
||||
mapping = await jira_mapping_manager.find_mapping(source, alert_data)
|
||||
|
||||
if not mapping:
|
||||
logger.warning(f"Маппинг не найден для источника {source}")
|
||||
return None
|
||||
|
||||
# Формируем данные тикета
|
||||
summary = _generate_jira_summary(alert, source, mapping)
|
||||
description = _generate_jira_description(alert, source, k8s_cluster, mapping)
|
||||
|
||||
# Создаем тикет
|
||||
try:
|
||||
issue_key = jira_client.create_issue(
|
||||
project=mapping.project,
|
||||
summary=summary,
|
||||
description=description,
|
||||
issue_type=mapping.issue_type,
|
||||
assignee=mapping.assignee,
|
||||
priority=mapping.priority,
|
||||
labels=mapping.labels
|
||||
)
|
||||
|
||||
if issue_key:
|
||||
logger.info(f"Jira тикет {issue_key} создан для алерта из {source}")
|
||||
# Увеличиваем счетчик созданных тикетов
|
||||
from app.core.metrics import metrics
|
||||
metrics.increment_jira_ticket_created(
|
||||
source=source,
|
||||
project=mapping.project,
|
||||
k8s_cluster=k8s_cluster or "",
|
||||
chat="",
|
||||
thread=0
|
||||
)
|
||||
|
||||
return issue_key
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Jira тикета: {e}")
|
||||
# Увеличиваем счетчик ошибок
|
||||
from app.core.metrics import metrics
|
||||
metrics.increment_jira_ticket_error(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster or "",
|
||||
chat="",
|
||||
thread=0
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_alert_data(
|
||||
alert: Any,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Извлечь данные алерта для маппинга.
|
||||
|
||||
Args:
|
||||
alert: Данные алерта.
|
||||
source: Источник алерта.
|
||||
k8s_cluster: Имя Kubernetes кластера.
|
||||
|
||||
Returns:
|
||||
Словарь с данными алерта.
|
||||
"""
|
||||
alert_data = {}
|
||||
|
||||
if source == "alertmanager" and isinstance(alert, PrometheusAlert):
|
||||
alert_data["status"] = alert.status
|
||||
alert_data.update(alert.commonLabels)
|
||||
alert_data.update(alert.commonAnnotations)
|
||||
if k8s_cluster:
|
||||
alert_data["k8s_cluster"] = k8s_cluster
|
||||
elif source == "grafana" and isinstance(alert, GrafanaAlert):
|
||||
alert_data["state"] = alert.state
|
||||
alert_data["tags"] = alert.tags
|
||||
alert_data["ruleName"] = alert.ruleName
|
||||
elif source == "zabbix" and isinstance(alert, ZabbixAlert):
|
||||
alert_data["status"] = alert.status
|
||||
alert_data["event-severity"] = alert.event_severity or ""
|
||||
alert_data["event-name"] = alert.event_name
|
||||
alert_data["host-name"] = alert.host_name
|
||||
|
||||
return alert_data
|
||||
|
||||
|
||||
def _generate_jira_summary(
|
||||
alert: Any,
|
||||
source: str,
|
||||
mapping: JiraMapping
|
||||
) -> str:
|
||||
"""
|
||||
Сгенерировать заголовок Jira тикета.
|
||||
|
||||
Args:
|
||||
alert: Данные алерта.
|
||||
source: Источник алерта.
|
||||
mapping: Маппинг для создания тикета.
|
||||
|
||||
Returns:
|
||||
Заголовок тикета.
|
||||
"""
|
||||
severity_prefix = ""
|
||||
alert_name = ""
|
||||
|
||||
if source == "alertmanager" and isinstance(alert, PrometheusAlert):
|
||||
severity = alert.commonLabels.get("severity", "")
|
||||
alert_name = alert.commonLabels.get("alertname", "")
|
||||
if severity:
|
||||
severity_prefix = f"[{severity.upper()}] "
|
||||
if alert_name:
|
||||
alert_name = add_spaces_to_alert_name(alert_name)
|
||||
summary = alert.commonAnnotations.get("summary", alert_name)
|
||||
elif source == "grafana" and isinstance(alert, GrafanaAlert):
|
||||
alert_name = alert.ruleName
|
||||
if alert.state == "alerting":
|
||||
severity_prefix = "[ALERTING] "
|
||||
summary = alert.title or alert_name
|
||||
elif source == "zabbix" and isinstance(alert, ZabbixAlert):
|
||||
severity = alert.event_severity or ""
|
||||
alert_name = alert.event_name
|
||||
if severity:
|
||||
severity_prefix = f"[{severity.upper()}] "
|
||||
summary = alert.alert_subject or alert_name
|
||||
else:
|
||||
summary = "Unknown Alert"
|
||||
|
||||
return f"{severity_prefix}{alert_name}: {summary}"[:255] # Ограничение Jira
|
||||
|
||||
|
||||
def _generate_jira_description(
|
||||
alert: Any,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
mapping: Optional[JiraMapping] = None
|
||||
) -> str:
|
||||
"""
|
||||
Сгенерировать описание Jira тикета.
|
||||
|
||||
Args:
|
||||
alert: Данные алерта.
|
||||
source: Источник алерта.
|
||||
k8s_cluster: Имя Kubernetes кластера.
|
||||
mapping: Маппинг для создания тикета.
|
||||
|
||||
Returns:
|
||||
Описание тикета в формате Markdown.
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
# Загружаем шаблон описания
|
||||
try:
|
||||
environment = Environment(loader=FileSystemLoader(settings.templates_path))
|
||||
template_name = f"jira_{source}.tmpl"
|
||||
try:
|
||||
template = environment.get_template(template_name)
|
||||
except Exception:
|
||||
# Если шаблон не найден, используем общий шаблон
|
||||
template = environment.get_template("jira_common.tmpl")
|
||||
except Exception:
|
||||
# Если общий шаблон не найден, используем простое описание
|
||||
return _generate_simple_description(alert, source, k8s_cluster)
|
||||
|
||||
# Формируем словарь для шаблона
|
||||
template_data = _prepare_template_data(alert, source, k8s_cluster)
|
||||
|
||||
# Рендерим шаблон
|
||||
description = template.render(template_data)
|
||||
|
||||
return description
|
||||
|
||||
|
||||
def _generate_simple_description(
|
||||
alert: Any,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Сгенерировать простое описание тикета без шаблона.
|
||||
|
||||
Args:
|
||||
alert: Данные алерта.
|
||||
source: Источник алерта.
|
||||
k8s_cluster: Имя Kubernetes кластера.
|
||||
|
||||
Returns:
|
||||
Простое описание тикета.
|
||||
"""
|
||||
description = f"**Источник:** {source}\n\n"
|
||||
|
||||
if source == "alertmanager" and isinstance(alert, PrometheusAlert):
|
||||
description += f"**Статус:** {alert.status}\n\n"
|
||||
description += "**Метки:**\n"
|
||||
for key, value in alert.commonLabels.items():
|
||||
description += f"- {key}: {value}\n"
|
||||
description += "\n**Аннотации:**\n"
|
||||
for key, value in alert.commonAnnotations.items():
|
||||
description += f"- {key}: {value}\n"
|
||||
if k8s_cluster:
|
||||
description += f"\n**Kubernetes кластер:** {k8s_cluster}\n"
|
||||
elif source == "grafana" and isinstance(alert, GrafanaAlert):
|
||||
description += f"**Состояние:** {alert.state}\n\n"
|
||||
description += f"**Правило:** {alert.ruleName}\n\n"
|
||||
description += f"**Сообщение:** {alert.message or 'Нет сообщения'}\n\n"
|
||||
if alert.tags:
|
||||
description += "**Теги:**\n"
|
||||
for key, value in alert.tags.items():
|
||||
description += f"- {key}: {value}\n"
|
||||
elif source == "zabbix" and isinstance(alert, ZabbixAlert):
|
||||
description += f"**Статус:** {alert.status}\n\n"
|
||||
description += f"**Серьезность:** {alert.event_severity or 'Unknown'}\n\n"
|
||||
description += f"**Событие:** {alert.event_name}\n\n"
|
||||
description += f"**Хост:** {alert.host_name} ({alert.host_ip})\n\n"
|
||||
description += f"**Сообщение:** {alert.alert_message}\n"
|
||||
|
||||
return description
|
||||
|
||||
|
||||
def _prepare_template_data(
|
||||
alert: Any,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Подготовить данные для шаблона описания тикета.
|
||||
|
||||
Args:
|
||||
alert: Данные алерта.
|
||||
source: Источник алерта.
|
||||
k8s_cluster: Имя Kubernetes кластера.
|
||||
|
||||
Returns:
|
||||
Словарь с данными для шаблона.
|
||||
"""
|
||||
template_data = {
|
||||
"source": source,
|
||||
"k8s_cluster": k8s_cluster or "",
|
||||
}
|
||||
|
||||
if source == "alertmanager" and isinstance(alert, PrometheusAlert):
|
||||
template_data["status"] = alert.status
|
||||
template_data["common_labels"] = alert.commonLabels
|
||||
template_data["common_annotations"] = alert.commonAnnotations
|
||||
template_data["alertname"] = alert.commonLabels.get("alertname", "")
|
||||
template_data["severity"] = alert.commonLabels.get("severity", "")
|
||||
template_data["summary"] = alert.commonAnnotations.get("summary", "")
|
||||
template_data["description"] = alert.commonAnnotations.get("description", "")
|
||||
elif source == "grafana" and isinstance(alert, GrafanaAlert):
|
||||
template_data["state"] = alert.state
|
||||
template_data["title"] = alert.title
|
||||
template_data["ruleName"] = alert.ruleName
|
||||
template_data["message"] = alert.message or ""
|
||||
template_data["tags"] = alert.tags
|
||||
template_data["evalMatches"] = alert.evalMatches
|
||||
elif source == "zabbix" and isinstance(alert, ZabbixAlert):
|
||||
template_data["status"] = alert.status
|
||||
template_data["event_severity"] = alert.event_severity or ""
|
||||
template_data["event_name"] = alert.event_name
|
||||
template_data["alert_subject"] = alert.alert_subject
|
||||
template_data["alert_message"] = alert.alert_message
|
||||
template_data["host_name"] = alert.host_name
|
||||
template_data["host_ip"] = alert.host_ip
|
||||
template_data["host_port"] = alert.host_port
|
||||
|
||||
return template_data
|
||||
102
app/core/messenger_factory.py
Normal file
102
app/core/messenger_factory.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Фабрика для создания клиентов мессенджеров.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from app.core.messengers.base import MessengerClient
|
||||
from app.core.messengers.telegram import TelegramMessengerClient
|
||||
from app.core.messengers.max import MaxMessengerClient
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MessengerFactory:
|
||||
"""Фабрика для создания клиентов мессенджеров."""
|
||||
|
||||
@staticmethod
|
||||
def create(messenger_type: str, **kwargs) -> MessengerClient:
|
||||
"""
|
||||
Создать клиент мессенджера.
|
||||
|
||||
Args:
|
||||
messenger_type: Тип мессенджера (telegram, max).
|
||||
**kwargs: Дополнительные параметры для инициализации клиента.
|
||||
|
||||
Returns:
|
||||
Экземпляр MessengerClient.
|
||||
|
||||
Raises:
|
||||
ValueError: Если тип мессенджера неизвестен.
|
||||
"""
|
||||
messenger_type = messenger_type.lower()
|
||||
|
||||
if messenger_type == "telegram":
|
||||
bot_token = kwargs.get("bot_token")
|
||||
return TelegramMessengerClient(bot_token=bot_token)
|
||||
elif messenger_type == "max":
|
||||
access_token = kwargs.get("access_token")
|
||||
api_version = kwargs.get("api_version", "5.131")
|
||||
return MaxMessengerClient(access_token=access_token, api_version=api_version)
|
||||
else:
|
||||
raise ValueError(f"Неизвестный тип мессенджера: {messenger_type}")
|
||||
|
||||
@staticmethod
|
||||
def create_from_config(group_config: Dict[str, Any]) -> MessengerClient:
|
||||
"""
|
||||
Создать клиент мессенджера из конфигурации группы.
|
||||
|
||||
Args:
|
||||
group_config: Конфигурация группы с полями:
|
||||
- messenger: Тип мессенджера (telegram, max)
|
||||
- config: Дополнительная конфигурация для мессенджера
|
||||
|
||||
Returns:
|
||||
Экземпляр MessengerClient.
|
||||
"""
|
||||
messenger_type = group_config.get("messenger", "telegram")
|
||||
config = group_config.get("config", {})
|
||||
|
||||
# Если конфигурация не указана, используем настройки из переменных окружения
|
||||
settings = get_settings()
|
||||
|
||||
if messenger_type == "telegram":
|
||||
bot_token = config.get("bot_token") or settings.telegram_bot_token
|
||||
return TelegramMessengerClient(bot_token=bot_token)
|
||||
elif messenger_type == "max":
|
||||
access_token = config.get("access_token") or settings.max_access_token
|
||||
api_version = config.get("api_version", settings.max_api_version or "5.131")
|
||||
return MaxMessengerClient(access_token=access_token, api_version=api_version)
|
||||
else:
|
||||
raise ValueError(f"Неизвестный тип мессенджера: {messenger_type}")
|
||||
|
||||
|
||||
# Глобальный кэш клиентов мессенджеров
|
||||
_messenger_clients_cache: Dict[str, MessengerClient] = {}
|
||||
|
||||
|
||||
def get_messenger_client(messenger_type: str, **kwargs) -> MessengerClient:
|
||||
"""
|
||||
Получить клиент мессенджера (с кэшированием).
|
||||
|
||||
Args:
|
||||
messenger_type: Тип мессенджера (telegram, max).
|
||||
**kwargs: Дополнительные параметры для инициализации клиента.
|
||||
|
||||
Returns:
|
||||
Экземпляр MessengerClient.
|
||||
"""
|
||||
# Для Telegram используем глобальный экземпляр, если bot_token не указан
|
||||
if messenger_type == "telegram" and "bot_token" not in kwargs:
|
||||
cache_key = "telegram_default"
|
||||
if cache_key not in _messenger_clients_cache:
|
||||
_messenger_clients_cache[cache_key] = MessengerFactory.create(messenger_type, **kwargs)
|
||||
return _messenger_clients_cache[cache_key]
|
||||
|
||||
# Для других мессенджеров создаем новый экземпляр
|
||||
return MessengerFactory.create(messenger_type, **kwargs)
|
||||
|
||||
16
app/core/messengers/__init__.py
Normal file
16
app/core/messengers/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Модуль для работы с различными мессенджерами.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
from app.core.messengers.base import MessengerClient
|
||||
from app.core.messengers.telegram import TelegramMessengerClient
|
||||
from app.core.messengers.max import MaxMessengerClient
|
||||
|
||||
__all__ = [
|
||||
"MessengerClient",
|
||||
"TelegramMessengerClient",
|
||||
"MaxMessengerClient",
|
||||
]
|
||||
|
||||
174
app/core/messengers/base.py
Normal file
174
app/core/messengers/base.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Базовый абстрактный класс для работы с мессенджерами.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Union, Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MessengerClient(ABC):
|
||||
"""Базовый абстрактный класс для всех мессенджеров."""
|
||||
|
||||
@abstractmethod
|
||||
async def send_message(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
text: str,
|
||||
thread_id: Optional[int] = None,
|
||||
reply_markup: Optional[Dict[str, Any]] = None,
|
||||
disable_web_page_preview: bool = True,
|
||||
parse_mode: str = "HTML",
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить текстовое сообщение.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы (может быть строкой или числом).
|
||||
text: Текст сообщения.
|
||||
thread_id: ID треда в группе (опционально, не все мессенджеры поддерживают).
|
||||
reply_markup: Клавиатура с кнопками (опционально, формат зависит от мессенджера).
|
||||
disable_web_page_preview: Отключить превью ссылок.
|
||||
parse_mode: Режим парсинга (HTML, Markdown, и т.д.).
|
||||
**kwargs: Дополнительные параметры для конкретного мессенджера.
|
||||
|
||||
Returns:
|
||||
True если сообщение отправлено успешно, False в противном случае.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def send_photo(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
photo: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить фото.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
photo: Путь к файлу, URL, bytes или BytesIO объект с фото.
|
||||
caption: Подпись к фото (опционально).
|
||||
thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если фото отправлено успешно, False в противном случае.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def send_video(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
video: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
duration: Optional[int] = None,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить видео.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
video: Путь к файлу, URL, bytes или BytesIO объект с видео.
|
||||
caption: Подпись к видео (опционально).
|
||||
thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
duration: Длительность видео в секундах (опционально).
|
||||
width: Ширина видео (опционально).
|
||||
height: Высота видео (опционально).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если видео отправлено успешно, False в противном случае.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def send_audio(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
audio: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
duration: Optional[int] = None,
|
||||
performer: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить аудио.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
audio: Путь к файлу, URL, bytes или BytesIO объект с аудио.
|
||||
caption: Подпись к аудио (опционально).
|
||||
thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
duration: Длительность аудио в секундах (опционально).
|
||||
performer: Исполнитель (опционально).
|
||||
title: Название трека (опционально).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если аудио отправлено успешно, False в противном случае.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def send_document(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
document: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
filename: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить документ.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
document: Путь к файлу, URL, bytes или BytesIO объект с документом.
|
||||
caption: Подпись к документу (опционально).
|
||||
thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
filename: Имя файла (опционально).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если документ отправлен успешно, False в противном случае.
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def messenger_type(self) -> str:
|
||||
"""Тип мессенджера (telegram, max, и т.д.)."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def supports_threads(self) -> bool:
|
||||
"""Поддерживает ли мессенджер треды."""
|
||||
pass
|
||||
|
||||
476
app/core/messengers/max.py
Normal file
476
app/core/messengers/max.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
Адаптер для работы с MAX/VK через MessengerClient интерфейс.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
import io
|
||||
from typing import Optional, Union, Dict, Any
|
||||
import httpx
|
||||
|
||||
from app.core.messengers.base import MessengerClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MaxMessengerClient(MessengerClient):
|
||||
"""Адаптер для MAX/VK, реализующий интерфейс MessengerClient."""
|
||||
|
||||
def __init__(self, access_token: str, api_version: str = "5.131"):
|
||||
"""
|
||||
Инициализация клиента MAX/VK.
|
||||
|
||||
Args:
|
||||
access_token: Access token для VK API.
|
||||
api_version: Версия VK API (по умолчанию 5.131).
|
||||
|
||||
Raises:
|
||||
ValueError: Если access_token не указан.
|
||||
"""
|
||||
if not access_token:
|
||||
raise ValueError("MAX_ACCESS_TOKEN не установлен")
|
||||
self.access_token = access_token
|
||||
self.api_version = api_version
|
||||
self.api_url = "https://api.vk.com/method"
|
||||
|
||||
@property
|
||||
def messenger_type(self) -> str:
|
||||
"""Тип мессенджера."""
|
||||
return "max"
|
||||
|
||||
@property
|
||||
def supports_threads(self) -> bool:
|
||||
"""MAX/VK не поддерживает треды."""
|
||||
return False
|
||||
|
||||
def _convert_html_to_vk_format(self, text: str) -> str:
|
||||
"""
|
||||
Конвертировать HTML в формат VK.
|
||||
|
||||
VK поддерживает свою разметку:
|
||||
- [bold]текст[/bold] - жирный
|
||||
- [italic]текст[/italic] - курсив
|
||||
- [code]текст[/code] - код
|
||||
|
||||
Args:
|
||||
text: Текст с HTML разметкой.
|
||||
|
||||
Returns:
|
||||
Текст с VK разметкой.
|
||||
"""
|
||||
# Простая конвертация HTML в VK формат
|
||||
# Заменяем <b> и </b> на [bold] и [/bold]
|
||||
text = text.replace("<b>", "[bold]").replace("</b>", "[/bold]")
|
||||
text = text.replace("<strong>", "[bold]").replace("</strong>", "[/bold]")
|
||||
|
||||
# Заменяем <i> и </i> на [italic] и [/italic]
|
||||
text = text.replace("<i>", "[italic]").replace("</i>", "[/italic]")
|
||||
text = text.replace("<em>", "[italic]").replace("</em>", "[/italic]")
|
||||
|
||||
# Заменяем <code> и </code> на [code] и [/code]
|
||||
text = text.replace("<code>", "[code]").replace("</code>", "[/code]")
|
||||
|
||||
# Заменяем <pre> и </pre> на [code] и [/code]
|
||||
text = text.replace("<pre>", "[code]").replace("</pre>", "[/code]")
|
||||
|
||||
# Заменяем <br> и <br/> на перенос строки
|
||||
text = text.replace("<br>", "\n").replace("<br/>", "\n").replace("<br />", "\n")
|
||||
|
||||
# Удаляем другие HTML теги (простая очистка)
|
||||
import re
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
|
||||
return text
|
||||
|
||||
async def _download_file(self, url: str) -> bytes:
|
||||
"""
|
||||
Скачать файл по URL.
|
||||
|
||||
Args:
|
||||
url: URL файла.
|
||||
|
||||
Returns:
|
||||
Байты файла.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
async def _upload_photo_to_vk(self, photo: Union[str, bytes], peer_id: Union[str, int]) -> Optional[str]:
|
||||
"""
|
||||
Загрузить фото в VK и получить attachment.
|
||||
|
||||
Args:
|
||||
photo: URL или bytes фото.
|
||||
peer_id: ID получателя.
|
||||
|
||||
Returns:
|
||||
Attachment string для VK API или None в случае ошибки.
|
||||
"""
|
||||
try:
|
||||
# Если photo - это URL, скачиваем файл
|
||||
if isinstance(photo, str) and (photo.startswith('http://') or photo.startswith('https://')):
|
||||
photo_bytes = await self._download_file(photo)
|
||||
elif isinstance(photo, bytes):
|
||||
photo_bytes = photo
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип photo: {type(photo)}")
|
||||
return None
|
||||
|
||||
# Получаем URL для загрузки фото
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Шаг 1: Получаем upload server
|
||||
upload_url_params = {
|
||||
"access_token": self.access_token,
|
||||
"peer_id": peer_id,
|
||||
"v": self.api_version
|
||||
}
|
||||
upload_url_response = await client.get(
|
||||
f"{self.api_url}/photos.getMessagesUploadServer",
|
||||
params=upload_url_params
|
||||
)
|
||||
upload_url_data = upload_url_response.json()
|
||||
|
||||
if "error" in upload_url_data:
|
||||
logger.error(f"Ошибка получения upload server: {upload_url_data['error']}")
|
||||
return None
|
||||
|
||||
upload_url = upload_url_data["response"]["upload_url"]
|
||||
|
||||
# Шаг 2: Загружаем фото
|
||||
files = {"photo": ("photo.jpg", photo_bytes, "image/jpeg")}
|
||||
upload_response = await client.post(upload_url, files=files)
|
||||
upload_data = upload_response.json()
|
||||
|
||||
if "error" in upload_data:
|
||||
logger.error(f"Ошибка загрузки фото: {upload_data['error']}")
|
||||
return None
|
||||
|
||||
# Шаг 3: Сохраняем фото
|
||||
save_params = {
|
||||
"access_token": self.access_token,
|
||||
"server": upload_data["server"],
|
||||
"photo": upload_data["photo"],
|
||||
"hash": upload_data["hash"],
|
||||
"v": self.api_version
|
||||
}
|
||||
save_response = await client.get(
|
||||
f"{self.api_url}/photos.saveMessagesPhoto",
|
||||
params=save_params
|
||||
)
|
||||
save_data = save_response.json()
|
||||
|
||||
if "error" in save_data:
|
||||
logger.error(f"Ошибка сохранения фото: {save_data['error']}")
|
||||
return None
|
||||
|
||||
# Формируем attachment string
|
||||
photo_data = save_data["response"][0]
|
||||
attachment = f"photo{photo_data['owner_id']}_{photo_data['id']}"
|
||||
return attachment
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки фото в VK: {e}")
|
||||
return None
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
text: str,
|
||||
thread_id: Optional[int] = None,
|
||||
reply_markup: Optional[Dict[str, Any]] = None,
|
||||
disable_web_page_preview: bool = True,
|
||||
parse_mode: str = "HTML",
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить текстовое сообщение в MAX/VK.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы (может быть строкой или числом).
|
||||
text: Текст сообщения.
|
||||
thread_id: ID треда в группе (игнорируется для VK).
|
||||
reply_markup: Клавиатура с кнопками (опционально, формат VK).
|
||||
disable_web_page_preview: Отключить превью ссылок (игнорируется для VK).
|
||||
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
|
||||
**kwargs: Дополнительные параметры (attachment, и т.д.).
|
||||
|
||||
Returns:
|
||||
True если сообщение отправлено успешно, False в противном случае.
|
||||
"""
|
||||
if thread_id is not None:
|
||||
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
|
||||
|
||||
try:
|
||||
# Конвертируем HTML в формат VK
|
||||
if parse_mode == "HTML":
|
||||
text = self._convert_html_to_vk_format(text)
|
||||
|
||||
# Преобразуем chat_id в int
|
||||
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Генерируем random_id для VK API (должен быть уникальным для каждого сообщения)
|
||||
import random
|
||||
random_id = random.randint(1, 2**31 - 1)
|
||||
|
||||
# Параметры для отправки сообщения
|
||||
params = {
|
||||
"access_token": self.access_token,
|
||||
"peer_id": peer_id,
|
||||
"message": text,
|
||||
"v": self.api_version,
|
||||
"random_id": random_id # VK требует random_id
|
||||
}
|
||||
|
||||
# Добавляем attachment, если есть
|
||||
if "attachment" in kwargs:
|
||||
params["attachment"] = kwargs["attachment"]
|
||||
|
||||
# Добавляем клавиатуру, если есть
|
||||
if reply_markup:
|
||||
import json
|
||||
params["keyboard"] = json.dumps(reply_markup)
|
||||
|
||||
# Отправляем сообщение
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/messages.send",
|
||||
params=params
|
||||
)
|
||||
response_data = response.json()
|
||||
|
||||
if "error" in response_data:
|
||||
error = response_data["error"]
|
||||
logger.error(f"Ошибка отправки сообщения в VK: {error}")
|
||||
return False
|
||||
|
||||
message_id = response_data.get("response")
|
||||
if message_id:
|
||||
logger.info(f"Сообщение отправлено в VK чат {peer_id}, message_id: {message_id}")
|
||||
return True
|
||||
else:
|
||||
logger.error("Не удалось получить message_id из ответа VK API")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке сообщения в VK: {e}")
|
||||
return False
|
||||
|
||||
async def send_photo(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
photo: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить фото в MAX/VK.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
photo: URL или bytes фото.
|
||||
caption: Подпись к фото (опционально).
|
||||
thread_id: ID треда в группе (игнорируется для VK).
|
||||
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если фото отправлено успешно, False в противном случае.
|
||||
"""
|
||||
if thread_id is not None:
|
||||
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
|
||||
|
||||
try:
|
||||
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Загружаем фото в VK
|
||||
attachment = await self._upload_photo_to_vk(photo, peer_id)
|
||||
if not attachment:
|
||||
logger.error("Не удалось загрузить фото в VK")
|
||||
return False
|
||||
|
||||
# Формируем текст сообщения
|
||||
text = caption or ""
|
||||
if parse_mode == "HTML" and text:
|
||||
text = self._convert_html_to_vk_format(text)
|
||||
|
||||
# Отправляем сообщение с фото
|
||||
return await self.send_message(
|
||||
chat_id=peer_id,
|
||||
text=text,
|
||||
attachment=attachment,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке фото в VK: {e}")
|
||||
return False
|
||||
|
||||
async def send_video(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
video: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
duration: Optional[int] = None,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить видео в MAX/VK.
|
||||
|
||||
Примечание: VK API требует более сложную логику для загрузки видео.
|
||||
В текущей реализации отправляется только ссылка на видео.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
video: URL или bytes видео.
|
||||
caption: Подпись к видео (опционально).
|
||||
thread_id: ID треда в группе (игнорируется для VK).
|
||||
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
|
||||
duration: Длительность видео в секундах (игнорируется для VK).
|
||||
width: Ширина видео (игнорируется для VK).
|
||||
height: Высота видео (игнорируется для VK).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если видео отправлено успешно, False в противном случае.
|
||||
"""
|
||||
if thread_id is not None:
|
||||
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
|
||||
|
||||
try:
|
||||
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Если video - это URL, отправляем как ссылку
|
||||
if isinstance(video, str) and (video.startswith('http://') or video.startswith('https://')):
|
||||
text = caption or video
|
||||
if parse_mode == "HTML" and text:
|
||||
text = self._convert_html_to_vk_format(text)
|
||||
return await self.send_message(
|
||||
chat_id=peer_id,
|
||||
text=text,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
else:
|
||||
logger.warning("Загрузка видео через bytes пока не поддерживается в VK адаптере")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке видео в VK: {e}")
|
||||
return False
|
||||
|
||||
async def send_audio(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
audio: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
duration: Optional[int] = None,
|
||||
performer: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить аудио в MAX/VK.
|
||||
|
||||
Примечание: VK API требует специальную логику для загрузки аудио.
|
||||
В текущей реализации отправляется только ссылка на аудио.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
audio: URL или bytes аудио.
|
||||
caption: Подпись к аудио (опционально).
|
||||
thread_id: ID треда в группе (игнорируется для VK).
|
||||
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
|
||||
duration: Длительность аудио в секундах (игнорируется для VK).
|
||||
performer: Исполнитель (игнорируется для VK).
|
||||
title: Название трека (игнорируется для VK).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если аудио отправлено успешно, False в противном случае.
|
||||
"""
|
||||
if thread_id is not None:
|
||||
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
|
||||
|
||||
try:
|
||||
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Если audio - это URL, отправляем как ссылку
|
||||
if isinstance(audio, str) and (audio.startswith('http://') or audio.startswith('https://')):
|
||||
text = caption or audio
|
||||
if parse_mode == "HTML" and text:
|
||||
text = self._convert_html_to_vk_format(text)
|
||||
return await self.send_message(
|
||||
chat_id=peer_id,
|
||||
text=text,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
else:
|
||||
logger.warning("Загрузка аудио через bytes пока не поддерживается в VK адаптере")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке аудио в VK: {e}")
|
||||
return False
|
||||
|
||||
async def send_document(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
document: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
filename: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить документ в MAX/VK.
|
||||
|
||||
Примечание: VK API требует специальную логику для загрузки документов.
|
||||
В текущей реализации отправляется только ссылка на документ.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
document: URL или bytes документа.
|
||||
caption: Подпись к документу (опционально).
|
||||
thread_id: ID треда в группе (игнорируется для VK).
|
||||
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
|
||||
filename: Имя файла (игнорируется для VK).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если документ отправлен успешно, False в противном случае.
|
||||
"""
|
||||
if thread_id is not None:
|
||||
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
|
||||
|
||||
try:
|
||||
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Если document - это URL, отправляем как ссылку
|
||||
if isinstance(document, str) and (document.startswith('http://') or document.startswith('https://')):
|
||||
text = caption or document
|
||||
if parse_mode == "HTML" and text:
|
||||
text = self._convert_html_to_vk_format(text)
|
||||
return await self.send_message(
|
||||
chat_id=peer_id,
|
||||
text=text,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
else:
|
||||
logger.warning("Загрузка документов через bytes пока не поддерживается в VK адаптере")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке документа в VK: {e}")
|
||||
return False
|
||||
|
||||
255
app/core/messengers/telegram.py
Normal file
255
app/core/messengers/telegram.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
Адаптер для работы с Telegram через MessengerClient интерфейс.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
import io
|
||||
from typing import Optional, Union, Dict, Any
|
||||
from telegram import InlineKeyboardMarkup
|
||||
|
||||
from app.core.messengers.base import MessengerClient
|
||||
from app.core.telegram_client import TelegramClient
|
||||
from app.core.button_utils import convert_dict_to_telegram_buttons
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramMessengerClient(MessengerClient):
|
||||
"""Адаптер для Telegram, реализующий интерфейс MessengerClient."""
|
||||
|
||||
def __init__(self, bot_token: Optional[str] = None):
|
||||
"""
|
||||
Инициализация клиента Telegram.
|
||||
|
||||
Args:
|
||||
bot_token: Токен бота Telegram. Если не указан, используется из настроек.
|
||||
"""
|
||||
self._client = TelegramClient(bot_token=bot_token)
|
||||
|
||||
@property
|
||||
def messenger_type(self) -> str:
|
||||
"""Тип мессенджера."""
|
||||
return "telegram"
|
||||
|
||||
@property
|
||||
def supports_threads(self) -> bool:
|
||||
"""Telegram поддерживает треды."""
|
||||
return True
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
text: str,
|
||||
thread_id: Optional[int] = None,
|
||||
reply_markup: Optional[Dict[str, Any]] = None,
|
||||
disable_web_page_preview: bool = True,
|
||||
parse_mode: str = "HTML",
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить текстовое сообщение в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы (преобразуется в int).
|
||||
text: Текст сообщения.
|
||||
thread_id: ID треда в группе (опционально).
|
||||
reply_markup: Клавиатура с кнопками (опционально).
|
||||
disable_web_page_preview: Отключить превью ссылок.
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
**kwargs: Дополнительные параметры (игнорируются).
|
||||
|
||||
Returns:
|
||||
True если сообщение отправлено успешно, False в противном случае.
|
||||
"""
|
||||
# Преобразуем chat_id в int
|
||||
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Преобразуем reply_markup из Dict в InlineKeyboardMarkup, если нужно
|
||||
telegram_reply_markup = None
|
||||
if reply_markup:
|
||||
if isinstance(reply_markup, InlineKeyboardMarkup):
|
||||
telegram_reply_markup = reply_markup
|
||||
elif isinstance(reply_markup, dict):
|
||||
# Преобразуем словарь в InlineKeyboardMarkup
|
||||
telegram_reply_markup = convert_dict_to_telegram_buttons(reply_markup)
|
||||
|
||||
return await self._client.send_message(
|
||||
chat_id=chat_id_int,
|
||||
text=text,
|
||||
message_thread_id=thread_id,
|
||||
reply_markup=telegram_reply_markup,
|
||||
disable_web_page_preview=disable_web_page_preview,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
|
||||
async def send_photo(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
photo: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить фото в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы (преобразуется в int).
|
||||
photo: Путь к файлу, URL, bytes или BytesIO объект с фото.
|
||||
caption: Подпись к фото (опционально).
|
||||
thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
**kwargs: Дополнительные параметры (игнорируются).
|
||||
|
||||
Returns:
|
||||
True если фото отправлено успешно, False в противном случае.
|
||||
"""
|
||||
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Преобразуем bytes в BytesIO, если нужно
|
||||
if isinstance(photo, bytes):
|
||||
photo = io.BytesIO(photo)
|
||||
|
||||
return await self._client.send_photo(
|
||||
chat_id=chat_id_int,
|
||||
photo=photo,
|
||||
caption=caption,
|
||||
message_thread_id=thread_id,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
|
||||
async def send_video(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
video: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
duration: Optional[int] = None,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить видео в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы (преобразуется в int).
|
||||
video: Путь к файлу, URL, bytes или BytesIO объект с видео.
|
||||
caption: Подпись к видео (опционально).
|
||||
thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
duration: Длительность видео в секундах (опционально).
|
||||
width: Ширина видео (опционально).
|
||||
height: Высота видео (опционально).
|
||||
**kwargs: Дополнительные параметры (игнорируются).
|
||||
|
||||
Returns:
|
||||
True если видео отправлено успешно, False в противном случае.
|
||||
"""
|
||||
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Преобразуем bytes в BytesIO, если нужно
|
||||
if isinstance(video, bytes):
|
||||
video = io.BytesIO(video)
|
||||
|
||||
return await self._client.send_video(
|
||||
chat_id=chat_id_int,
|
||||
video=video,
|
||||
caption=caption,
|
||||
message_thread_id=thread_id,
|
||||
parse_mode=parse_mode,
|
||||
duration=duration,
|
||||
width=width,
|
||||
height=height
|
||||
)
|
||||
|
||||
async def send_audio(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
audio: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
duration: Optional[int] = None,
|
||||
performer: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить аудио в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы (преобразуется в int).
|
||||
audio: Путь к файлу, URL, bytes или BytesIO объект с аудио.
|
||||
caption: Подпись к аудио (опционально).
|
||||
thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
duration: Длительность аудио в секундах (опционально).
|
||||
performer: Исполнитель (опционально).
|
||||
title: Название трека (опционально).
|
||||
**kwargs: Дополнительные параметры (игнорируются).
|
||||
|
||||
Returns:
|
||||
True если аудио отправлено успешно, False в противном случае.
|
||||
"""
|
||||
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Преобразуем bytes в BytesIO, если нужно
|
||||
if isinstance(audio, bytes):
|
||||
audio = io.BytesIO(audio)
|
||||
|
||||
return await self._client.send_audio(
|
||||
chat_id=chat_id_int,
|
||||
audio=audio,
|
||||
caption=caption,
|
||||
message_thread_id=thread_id,
|
||||
parse_mode=parse_mode,
|
||||
duration=duration,
|
||||
performer=performer,
|
||||
title=title
|
||||
)
|
||||
|
||||
async def send_document(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
document: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
filename: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить документ в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы (преобразуется в int).
|
||||
document: Путь к файлу, URL, bytes или BytesIO объект с документом.
|
||||
caption: Подпись к документу (опционально).
|
||||
thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
filename: Имя файла (опционально).
|
||||
**kwargs: Дополнительные параметры (игнорируются).
|
||||
|
||||
Returns:
|
||||
True если документ отправлен успешно, False в противном случае.
|
||||
"""
|
||||
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Преобразуем bytes в BytesIO, если нужно
|
||||
if isinstance(document, bytes):
|
||||
document = io.BytesIO(document)
|
||||
|
||||
return await self._client.send_document(
|
||||
chat_id=chat_id_int,
|
||||
document=document,
|
||||
caption=caption,
|
||||
message_thread_id=thread_id,
|
||||
parse_mode=parse_mode,
|
||||
filename=filename
|
||||
)
|
||||
|
||||
316
app/core/metrics.py
Normal file
316
app/core/metrics.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
Централизованное управление метриками Prometheus.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
from prometheus_client import Counter, CollectorRegistry, push_to_gateway
|
||||
from functools import lru_cache
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetricsManager:
|
||||
"""Менеджер метрик Prometheus."""
|
||||
|
||||
def __init__(self):
|
||||
"""Инициализация менеджера метрик."""
|
||||
self.registry = CollectorRegistry()
|
||||
self._init_metrics()
|
||||
|
||||
def _init_metrics(self) -> None:
|
||||
"""Инициализация всех метрик."""
|
||||
# API эндпоинты
|
||||
self.api_endpoint_count = Counter(
|
||||
'tg_monitoring_gateway_api_endpoint_total',
|
||||
'Общее количество обращений к эндпоинтам API',
|
||||
labelnames=['endpoint'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
# Сообщения по источникам
|
||||
self.total_messages = Counter(
|
||||
'tg_monitoring_gateway_total_messages',
|
||||
'Всего сообщений получено',
|
||||
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
self.sent_messages = Counter(
|
||||
'tg_monitoring_gateway_sent_messages',
|
||||
'Сообщений успешно отправлено',
|
||||
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
self.reject_messages = Counter(
|
||||
'tg_monitoring_gateway_reject_messages',
|
||||
'Сообщений отклонено (стоп-слова)',
|
||||
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
self.error_messages = Counter(
|
||||
'tg_monitoring_gateway_error_messages',
|
||||
'Ошибок отправки сообщений',
|
||||
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
self.firing_messages = Counter(
|
||||
'tg_monitoring_gateway_firing_messages',
|
||||
'Горящих алертов',
|
||||
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
self.critical_messages = Counter(
|
||||
'tg_monitoring_gateway_critical_messages',
|
||||
'Критических алертов',
|
||||
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
self.resolved_messages = Counter(
|
||||
'tg_monitoring_gateway_resolved_messages',
|
||||
'Исправленных алертов',
|
||||
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
# Jira метрики
|
||||
self.jira_tickets_created = Counter(
|
||||
'tg_monitoring_gateway_jira_tickets_created',
|
||||
'Jira тикетов создано',
|
||||
labelnames=['source', 'k8s_cluster', 'chat', 'thread', 'project'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
self.jira_tickets_errors = Counter(
|
||||
'tg_monitoring_gateway_jira_tickets_errors',
|
||||
'Ошибок создания Jira тикетов',
|
||||
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
def increment_api_endpoint(self, endpoint: str) -> None:
|
||||
"""
|
||||
Увеличить счетчик обращений к эндпоинту API.
|
||||
|
||||
Args:
|
||||
endpoint: Имя эндпоинта.
|
||||
"""
|
||||
self.api_endpoint_count.labels(endpoint=endpoint).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def increment_total_message(
|
||||
self,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
chat: Optional[str] = None,
|
||||
thread: Optional[int] = None
|
||||
) -> None:
|
||||
"""
|
||||
Увеличить счетчик полученных сообщений.
|
||||
|
||||
Args:
|
||||
source: Источник сообщения (grafana, zabbix, alertmanager).
|
||||
k8s_cluster: Имя Kubernetes кластера (опционально).
|
||||
chat: Имя чата (опционально).
|
||||
thread: ID треда (опционально).
|
||||
"""
|
||||
k8s_cluster = k8s_cluster or ""
|
||||
chat = chat or ""
|
||||
thread = thread or 0
|
||||
self.total_messages.labels(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster,
|
||||
chat=chat,
|
||||
thread=thread
|
||||
).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def increment_sent_message(
|
||||
self,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
chat: Optional[str] = None,
|
||||
thread: Optional[int] = None
|
||||
) -> None:
|
||||
"""Увеличить счетчик отправленных сообщений."""
|
||||
k8s_cluster = k8s_cluster or ""
|
||||
chat = chat or ""
|
||||
thread = thread or 0
|
||||
self.sent_messages.labels(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster,
|
||||
chat=chat,
|
||||
thread=thread
|
||||
).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def increment_reject_message(
|
||||
self,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
chat: Optional[str] = None,
|
||||
thread: Optional[int] = None
|
||||
) -> None:
|
||||
"""Увеличить счетчик отклоненных сообщений."""
|
||||
k8s_cluster = k8s_cluster or ""
|
||||
chat = chat or ""
|
||||
thread = thread or 0
|
||||
self.reject_messages.labels(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster,
|
||||
chat=chat,
|
||||
thread=thread
|
||||
).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def increment_error_message(
|
||||
self,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
chat: Optional[str] = None,
|
||||
thread: Optional[int] = None
|
||||
) -> None:
|
||||
"""Увеличить счетчик ошибок отправки."""
|
||||
k8s_cluster = k8s_cluster or ""
|
||||
chat = chat or ""
|
||||
thread = thread or 0
|
||||
self.error_messages.labels(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster,
|
||||
chat=chat,
|
||||
thread=thread
|
||||
).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def increment_firing_message(
|
||||
self,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
chat: Optional[str] = None,
|
||||
thread: Optional[int] = None
|
||||
) -> None:
|
||||
"""Увеличить счетчик горящих алертов."""
|
||||
k8s_cluster = k8s_cluster or ""
|
||||
chat = chat or ""
|
||||
thread = thread or 0
|
||||
self.firing_messages.labels(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster,
|
||||
chat=chat,
|
||||
thread=thread
|
||||
).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def increment_critical_message(
|
||||
self,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
chat: Optional[str] = None,
|
||||
thread: Optional[int] = None
|
||||
) -> None:
|
||||
"""Увеличить счетчик критических алертов."""
|
||||
k8s_cluster = k8s_cluster or ""
|
||||
chat = chat or ""
|
||||
thread = thread or 0
|
||||
self.critical_messages.labels(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster,
|
||||
chat=chat,
|
||||
thread=thread
|
||||
).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def increment_resolved_message(
|
||||
self,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
chat: Optional[str] = None,
|
||||
thread: Optional[int] = None
|
||||
) -> None:
|
||||
"""Увеличить счетчик исправленных алертов."""
|
||||
k8s_cluster = k8s_cluster or ""
|
||||
chat = chat or ""
|
||||
thread = thread or 0
|
||||
self.resolved_messages.labels(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster,
|
||||
chat=chat,
|
||||
thread=thread
|
||||
).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def increment_jira_ticket_created(
|
||||
self,
|
||||
source: str,
|
||||
project: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
chat: Optional[str] = None,
|
||||
thread: Optional[int] = None
|
||||
) -> None:
|
||||
"""Увеличить счетчик созданных Jira тикетов."""
|
||||
k8s_cluster = k8s_cluster or ""
|
||||
chat = chat or ""
|
||||
thread = thread or 0
|
||||
self.jira_tickets_created.labels(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster,
|
||||
chat=chat,
|
||||
thread=thread,
|
||||
project=project
|
||||
).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def increment_jira_ticket_error(
|
||||
self,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
chat: Optional[str] = None,
|
||||
thread: Optional[int] = None
|
||||
) -> None:
|
||||
"""Увеличить счетчик ошибок создания Jira тикетов."""
|
||||
k8s_cluster = k8s_cluster or ""
|
||||
chat = chat or ""
|
||||
thread = thread or 0
|
||||
self.jira_tickets_errors.labels(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster,
|
||||
chat=chat,
|
||||
thread=thread
|
||||
).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def _push_metrics(self) -> None:
|
||||
"""Отправить метрики в Pushgateway."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.pushgateway_url:
|
||||
return
|
||||
|
||||
try:
|
||||
push_to_gateway(
|
||||
settings.pushgateway_url,
|
||||
job=settings.pushgateway_job,
|
||||
registry=self.registry
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки метрик в Pushgateway: {e}")
|
||||
|
||||
|
||||
# Глобальный экземпляр менеджера метрик
|
||||
@lru_cache(maxsize=1)
|
||||
def get_metrics_manager() -> MetricsManager:
|
||||
"""Получить глобальный экземпляр менеджера метрик."""
|
||||
return MetricsManager()
|
||||
|
||||
|
||||
metrics = get_metrics_manager()
|
||||
345
app/core/telegram_client.py
Normal file
345
app/core/telegram_client.py
Normal file
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
Клиент для работы с Telegram Bot API.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
import io
|
||||
from typing import Optional, Union
|
||||
from telegram import Bot, InlineKeyboardMarkup
|
||||
from telegram.error import TelegramError
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramClient:
|
||||
"""Клиент для отправки сообщений в Telegram."""
|
||||
|
||||
def __init__(self, bot_token: Optional[str] = None):
|
||||
"""
|
||||
Инициализация клиента Telegram.
|
||||
|
||||
Args:
|
||||
bot_token: Токен бота Telegram. Если не указан, используется из настроек.
|
||||
|
||||
Raises:
|
||||
ValueError: Если токен не указан.
|
||||
"""
|
||||
# Импортируем settings здесь, чтобы избежать циклических зависимостей
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
self.bot_token = bot_token or settings.telegram_bot_token
|
||||
if not self.bot_token:
|
||||
raise ValueError("TELEGRAM_BOT_TOKEN не установлен")
|
||||
self._bot: Optional[Bot] = None
|
||||
|
||||
async def get_bot(self) -> Bot:
|
||||
"""
|
||||
Получить экземпляр бота (создается при первом обращении).
|
||||
|
||||
Returns:
|
||||
Экземпляр Bot.
|
||||
"""
|
||||
if self._bot is None:
|
||||
self._bot = Bot(token=self.bot_token)
|
||||
return self._bot
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
chat_id: int,
|
||||
text: str,
|
||||
message_thread_id: Optional[int] = None,
|
||||
reply_markup: Optional[InlineKeyboardMarkup] = None,
|
||||
disable_web_page_preview: bool = True,
|
||||
parse_mode: str = "HTML"
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить сообщение в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
text: Текст сообщения.
|
||||
message_thread_id: ID треда в группе (опционально).
|
||||
reply_markup: Клавиатура с кнопками (опционально).
|
||||
disable_web_page_preview: Отключить превью ссылок.
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
|
||||
Returns:
|
||||
True если сообщение отправлено успешно, False в противном случае.
|
||||
"""
|
||||
try:
|
||||
bot = await self.get_bot()
|
||||
await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
message_thread_id=message_thread_id,
|
||||
disable_web_page_preview=disable_web_page_preview,
|
||||
parse_mode=parse_mode,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
logger.info(f"Сообщение отправлено в чат {chat_id}, тред {message_thread_id}")
|
||||
return True
|
||||
except TelegramError as e:
|
||||
logger.error(f"Ошибка отправки сообщения в Telegram: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке сообщения: {e}")
|
||||
return False
|
||||
|
||||
async def send_photo(
|
||||
self,
|
||||
chat_id: int,
|
||||
photo: Union[str, bytes, io.BytesIO],
|
||||
caption: Optional[str] = None,
|
||||
message_thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML"
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить фото в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
photo: Путь к файлу, URL, bytes или BytesIO объект с фото.
|
||||
caption: Подпись к фото (опционально).
|
||||
message_thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
|
||||
Returns:
|
||||
True если фото отправлено успешно, False в противном случае.
|
||||
"""
|
||||
try:
|
||||
bot = await self.get_bot()
|
||||
|
||||
# Если photo - это URL, скачиваем файл
|
||||
if isinstance(photo, str) and (photo.startswith('http://') or photo.startswith('https://')):
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(photo)
|
||||
photo = io.BytesIO(response.content)
|
||||
|
||||
# Если photo - это bytes, преобразуем в BytesIO
|
||||
if isinstance(photo, bytes):
|
||||
photo = io.BytesIO(photo)
|
||||
|
||||
await bot.send_photo(
|
||||
chat_id=chat_id,
|
||||
photo=photo,
|
||||
caption=caption,
|
||||
message_thread_id=message_thread_id,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
logger.info(f"Фото отправлено в чат {chat_id}, тред {message_thread_id}")
|
||||
return True
|
||||
except TelegramError as e:
|
||||
logger.error(f"Ошибка отправки фото в Telegram: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке фото: {e}")
|
||||
return False
|
||||
|
||||
async def send_video(
|
||||
self,
|
||||
chat_id: int,
|
||||
video: Union[str, bytes, io.BytesIO],
|
||||
caption: Optional[str] = None,
|
||||
message_thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
duration: Optional[int] = None,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить видео в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
video: Путь к файлу, URL, bytes или BytesIO объект с видео.
|
||||
caption: Подпись к видео (опционально).
|
||||
message_thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
duration: Длительность видео в секундах (опционально).
|
||||
width: Ширина видео (опционально).
|
||||
height: Высота видео (опционально).
|
||||
|
||||
Returns:
|
||||
True если видео отправлено успешно, False в противном случае.
|
||||
"""
|
||||
try:
|
||||
bot = await self.get_bot()
|
||||
|
||||
# Если video - это URL, скачиваем файл
|
||||
if isinstance(video, str) and (video.startswith('http://') or video.startswith('https://')):
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(video)
|
||||
video = io.BytesIO(response.content)
|
||||
|
||||
# Если video - это bytes, преобразуем в BytesIO
|
||||
if isinstance(video, bytes):
|
||||
video = io.BytesIO(video)
|
||||
|
||||
await bot.send_video(
|
||||
chat_id=chat_id,
|
||||
video=video,
|
||||
caption=caption,
|
||||
message_thread_id=message_thread_id,
|
||||
parse_mode=parse_mode,
|
||||
duration=duration,
|
||||
width=width,
|
||||
height=height
|
||||
)
|
||||
logger.info(f"Видео отправлено в чат {chat_id}, тред {message_thread_id}")
|
||||
return True
|
||||
except TelegramError as e:
|
||||
logger.error(f"Ошибка отправки видео в Telegram: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке видео: {e}")
|
||||
return False
|
||||
|
||||
async def send_audio(
|
||||
self,
|
||||
chat_id: int,
|
||||
audio: Union[str, bytes, io.BytesIO],
|
||||
caption: Optional[str] = None,
|
||||
message_thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
duration: Optional[int] = None,
|
||||
performer: Optional[str] = None,
|
||||
title: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить аудио в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
audio: Путь к файлу, URL, bytes или BytesIO объект с аудио.
|
||||
caption: Подпись к аудио (опционально).
|
||||
message_thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
duration: Длительность аудио в секундах (опционально).
|
||||
performer: Исполнитель (опционально).
|
||||
title: Название трека (опционально).
|
||||
|
||||
Returns:
|
||||
True если аудио отправлено успешно, False в противном случае.
|
||||
"""
|
||||
try:
|
||||
bot = await self.get_bot()
|
||||
|
||||
# Если audio - это URL, скачиваем файл
|
||||
if isinstance(audio, str) and (audio.startswith('http://') or audio.startswith('https://')):
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(audio)
|
||||
audio = io.BytesIO(response.content)
|
||||
|
||||
# Если audio - это bytes, преобразуем в BytesIO
|
||||
if isinstance(audio, bytes):
|
||||
audio = io.BytesIO(audio)
|
||||
|
||||
await bot.send_audio(
|
||||
chat_id=chat_id,
|
||||
audio=audio,
|
||||
caption=caption,
|
||||
message_thread_id=message_thread_id,
|
||||
parse_mode=parse_mode,
|
||||
duration=duration,
|
||||
performer=performer,
|
||||
title=title
|
||||
)
|
||||
logger.info(f"Аудио отправлено в чат {chat_id}, тред {message_thread_id}")
|
||||
return True
|
||||
except TelegramError as e:
|
||||
logger.error(f"Ошибка отправки аудио в Telegram: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке аудио: {e}")
|
||||
return False
|
||||
|
||||
async def send_document(
|
||||
self,
|
||||
chat_id: int,
|
||||
document: Union[str, bytes, io.BytesIO],
|
||||
caption: Optional[str] = None,
|
||||
message_thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
filename: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить документ в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
document: Путь к файлу, URL, bytes или BytesIO объект с документом.
|
||||
caption: Подпись к документу (опционально).
|
||||
message_thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
filename: Имя файла (опционально).
|
||||
|
||||
Returns:
|
||||
True если документ отправлен успешно, False в противном случае.
|
||||
"""
|
||||
try:
|
||||
bot = await self.get_bot()
|
||||
document_url = None
|
||||
|
||||
# Если document - это URL, скачиваем файл
|
||||
if isinstance(document, str) and (document.startswith('http://') or document.startswith('https://')):
|
||||
document_url = document
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(document)
|
||||
document = io.BytesIO(response.content)
|
||||
if not filename:
|
||||
# Пытаемся извлечь имя файла из URL
|
||||
filename = document_url.split('/')[-1].split('?')[0]
|
||||
|
||||
# Если document - это bytes, преобразуем в BytesIO
|
||||
if isinstance(document, bytes):
|
||||
document = io.BytesIO(document)
|
||||
|
||||
await bot.send_document(
|
||||
chat_id=chat_id,
|
||||
document=document,
|
||||
caption=caption,
|
||||
message_thread_id=message_thread_id,
|
||||
parse_mode=parse_mode,
|
||||
filename=filename
|
||||
)
|
||||
logger.info(f"Документ отправлен в чат {chat_id}, тред {message_thread_id}")
|
||||
return True
|
||||
except TelegramError as e:
|
||||
logger.error(f"Ошибка отправки документа в Telegram: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке документа: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Глобальный экземпляр клиента (lazy initialization)
|
||||
_telegram_client_instance: Optional[TelegramClient] = None
|
||||
|
||||
|
||||
def get_telegram_client() -> TelegramClient:
|
||||
"""
|
||||
Получить экземпляр клиента Telegram (lazy initialization).
|
||||
|
||||
Returns:
|
||||
Экземпляр TelegramClient.
|
||||
"""
|
||||
global _telegram_client_instance
|
||||
if _telegram_client_instance is None:
|
||||
_telegram_client_instance = TelegramClient()
|
||||
return _telegram_client_instance
|
||||
|
||||
|
||||
# Для обратной совместимости (lazy initialization)
|
||||
# telegram_client будет создан при первом использовании
|
||||
class _TelegramClientProxy:
|
||||
"""Прокси для ленивой инициализации telegram_client."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Получить атрибут из telegram_client."""
|
||||
return getattr(get_telegram_client(), name)
|
||||
|
||||
|
||||
telegram_client = _TelegramClientProxy()
|
||||
97
app/core/utils.py
Normal file
97
app/core/utils.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Вспомогательные утилиты.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Список стоп-слов (алерты, которые не должны отправляться)
|
||||
STOP_WORDS = [
|
||||
r"^InfoInhibitor",
|
||||
r"^Watchdog",
|
||||
r"^[E,e]tcdHighCommitDurations",
|
||||
r"^[E,e]tcdHighNumberOfFailedGRPCRequests",
|
||||
r"^[K,k]ubePersistentVolumeFillingUp",
|
||||
r"^[K,k]ubePersistentVolumeInodesFillingUp",
|
||||
]
|
||||
|
||||
|
||||
def check_stop_words(name: str) -> bool:
|
||||
"""
|
||||
Проверить, содержит ли название алерта стоп-слова.
|
||||
|
||||
Args:
|
||||
name: Название алерта.
|
||||
|
||||
Returns:
|
||||
True если алерт должен быть заблокирован, False в противном случае.
|
||||
"""
|
||||
logger.debug(f"Проверка стоп-слов для алерта: '{name}'")
|
||||
|
||||
for pattern in STOP_WORDS:
|
||||
if re.search(pattern, name):
|
||||
logger.warning(f"Алерт '{name}' заблокирован стоп-словом: {pattern}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def add_spaces_to_alert_name(name: str) -> str:
|
||||
"""
|
||||
Добавить пробелы в название алерта для лучшей читаемости.
|
||||
|
||||
Пример: "HighCPUUsage" -> "High CPU Usage"
|
||||
|
||||
Args:
|
||||
name: Название алерта без пробелов.
|
||||
|
||||
Returns:
|
||||
Название алерта с пробелами.
|
||||
"""
|
||||
if not name:
|
||||
return name
|
||||
|
||||
result = name[0]
|
||||
for letter in name[1:]:
|
||||
if letter.isupper():
|
||||
result += f' {letter}'
|
||||
else:
|
||||
result += letter
|
||||
|
||||
# Исправляем известные сокращения
|
||||
result = result.replace('C P U', 'CPU')
|
||||
result = result.replace('etcd', 'ETCD')
|
||||
result = result.replace('A P I', 'API')
|
||||
result = result.replace('K 8 S', 'K8S')
|
||||
result = result.replace('P V C', 'PVC')
|
||||
result = result.replace('G R P C', 'GRPC')
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def truncate_message(message: str, max_length: int = 4090) -> str:
|
||||
"""
|
||||
Обрезать сообщение до максимальной длины для Telegram.
|
||||
|
||||
Telegram имеет лимит в 4096 символов на сообщение.
|
||||
|
||||
Args:
|
||||
message: Исходное сообщение.
|
||||
max_length: Максимальная длина сообщения.
|
||||
|
||||
Returns:
|
||||
Обрезанное сообщение с индикацией обрезки.
|
||||
"""
|
||||
if len(message) <= max_length:
|
||||
return message
|
||||
|
||||
truncated = message[:max_length - 10]
|
||||
truncated += "\n\n... (сообщение обрезано)"
|
||||
logger.warning(f"Сообщение обрезано с {len(message)} до {max_length} символов")
|
||||
return truncated
|
||||
187
app/main.py
Normal file
187
app/main.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Главный модуль приложения Telegram Gateway.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.common.cors import add as add_cors
|
||||
from app.common.telemetry import add as add_telemetry
|
||||
from app.api.v1.router import api_router
|
||||
|
||||
# Настройка логирования (базовая настройка, детальная настройка в app.common.logger)
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Создаем приложение FastAPI
|
||||
app = FastAPI(
|
||||
title="Message Gateway",
|
||||
summary="Отправляем мониторинговые алерты в телеграм/MAX и создаем тикеты в Jira",
|
||||
description=(
|
||||
"Приложение для оповещений, приходящих из Grafana/Zabbix/AlertManager "
|
||||
"посредством вебхука, в телеграм/MAX. С возможностью отправок в треды и создания тикетов в Jira. "
|
||||
"<br><br><b>Что бы начать отправлять сообщения</b>, добавьте бота "
|
||||
"<b>@CismGlobalMonitoring_bot</b> в чат и <b>внесите изменения в группы</b>"
|
||||
),
|
||||
version="0.2.0",
|
||||
contact={
|
||||
"name": "Сергей Антропов",
|
||||
"url": "https://devops.org.ru/contact/",
|
||||
"email": "sergey@antropoff.ru",
|
||||
},
|
||||
license_info={
|
||||
"name": "Apache 2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
|
||||
},
|
||||
debug=False,
|
||||
swagger_ui_init_oauth={
|
||||
"clientId": "api-key",
|
||||
"appName": "Message Gateway API",
|
||||
"usePkceWithAuthorizationCodeGrant": False,
|
||||
}
|
||||
)
|
||||
|
||||
# Добавляем схему безопасности в Swagger
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
def custom_openapi():
|
||||
if app.openapi_schema:
|
||||
return app.openapi_schema
|
||||
|
||||
# Собираем пути, которые должны быть скрыты от API
|
||||
hidden_paths = set()
|
||||
|
||||
# Перебираем все routes и находим те, которые помечены как скрытые
|
||||
for route in app.routes:
|
||||
# Проверяем только APIRoute
|
||||
if not isinstance(route, APIRoute):
|
||||
continue
|
||||
|
||||
# Получаем endpoint функцию
|
||||
endpoint = route.endpoint
|
||||
|
||||
# Проверяем, помечен ли endpoint как скрытый от API
|
||||
if hasattr(endpoint, "__hide_from_api__") and endpoint.__hide_from_api__:
|
||||
# Получаем полный путь (с учетом префиксов)
|
||||
path = route.path
|
||||
# Нормализуем путь (убираем параметры типа {param} для сопоставления)
|
||||
# Но нам нужно точное сопоставление, поэтому используем полный путь
|
||||
hidden_paths.add(path)
|
||||
logger.debug(f"Эндпоинт {path} помечен как скрытый от API")
|
||||
|
||||
# Генерируем схему как обычно
|
||||
openapi_schema = get_openapi(
|
||||
title=app.title,
|
||||
version=app.version,
|
||||
description=app.description,
|
||||
routes=app.routes,
|
||||
)
|
||||
|
||||
# Удаляем скрытые пути из схемы
|
||||
if hidden_paths:
|
||||
paths = openapi_schema.get("paths", {})
|
||||
# Создаем новый словарь paths без скрытых путей
|
||||
filtered_paths = {}
|
||||
|
||||
for path, path_item in paths.items():
|
||||
# Проверяем, должен ли путь быть скрыт
|
||||
# Путь в схеме должен точно совпадать с путем в route.path
|
||||
# route.path уже содержит все префиксы (router prefix + route path)
|
||||
should_hide = path in hidden_paths
|
||||
|
||||
if not should_hide:
|
||||
filtered_paths[path] = path_item
|
||||
else:
|
||||
logger.debug(f"Удаляем путь {path} из OpenAPI схемы")
|
||||
|
||||
openapi_schema["paths"] = filtered_paths
|
||||
|
||||
# Добавляем схему безопасности API Key
|
||||
if "components" not in openapi_schema:
|
||||
openapi_schema["components"] = {}
|
||||
if "securitySchemes" not in openapi_schema["components"]:
|
||||
openapi_schema["components"]["securitySchemes"] = {}
|
||||
|
||||
# Определяем имя схемы безопасности, которое использует FastAPI
|
||||
# FastAPI автоматически генерирует имя на основе класса Security
|
||||
# Обычно это имя класса в camelCase (например, "APIKeyHeader")
|
||||
api_key_scheme_name = None
|
||||
|
||||
# Перебираем существующие схемы безопасности и находим API Key схему
|
||||
for scheme_name, scheme_def in openapi_schema["components"].get("securitySchemes", {}).items():
|
||||
if scheme_def.get("type") == "apiKey" and scheme_def.get("name") == "X-API-Key":
|
||||
api_key_scheme_name = scheme_name
|
||||
break
|
||||
|
||||
# Если схема не найдена, создаем новую с именем "ApiKeyAuth"
|
||||
if not api_key_scheme_name:
|
||||
api_key_scheme_name = "ApiKeyAuth"
|
||||
openapi_schema["components"]["securitySchemes"][api_key_scheme_name] = {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key",
|
||||
"description": "API ключ для авторизации. Получите его из переменной окружения API_KEY."
|
||||
}
|
||||
else:
|
||||
# Обновляем существующую схему, если она уже есть
|
||||
openapi_schema["components"]["securitySchemes"][api_key_scheme_name] = {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key",
|
||||
"description": "API ключ для авторизации. Получите его из переменной окружения API_KEY."
|
||||
}
|
||||
# Если имя схемы не "ApiKeyAuth", переименовываем ее
|
||||
if api_key_scheme_name != "ApiKeyAuth":
|
||||
# Сохраняем старую схему
|
||||
old_scheme = openapi_schema["components"]["securitySchemes"].pop(api_key_scheme_name)
|
||||
# Создаем новую схему с именем "ApiKeyAuth"
|
||||
openapi_schema["components"]["securitySchemes"]["ApiKeyAuth"] = old_scheme
|
||||
|
||||
# Заменяем все использования старого имени на новое в security эндпоинтов
|
||||
for path, path_item in openapi_schema.get("paths", {}).items():
|
||||
for method, operation in path_item.items():
|
||||
if isinstance(operation, dict) and "security" in operation:
|
||||
security_list = operation["security"]
|
||||
for security_item in security_list:
|
||||
if api_key_scheme_name in security_item:
|
||||
# Заменяем старое имя на новое
|
||||
security_item["ApiKeyAuth"] = security_item.pop(api_key_scheme_name)
|
||||
|
||||
# Убеждаемся, что схема "ApiKeyAuth" существует
|
||||
if "ApiKeyAuth" not in openapi_schema["components"]["securitySchemes"]:
|
||||
openapi_schema["components"]["securitySchemes"]["ApiKeyAuth"] = {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key",
|
||||
"description": "API ключ для авторизации. Получите его из переменной окружения API_KEY."
|
||||
}
|
||||
|
||||
app.openapi_schema = openapi_schema
|
||||
return app.openapi_schema
|
||||
|
||||
app.openapi = custom_openapi
|
||||
|
||||
# Добавляем CORS
|
||||
add_cors(app)
|
||||
|
||||
# Добавляем телеметрию
|
||||
add_telemetry(app)
|
||||
|
||||
# Подключаем роутер API v1
|
||||
app.include_router(api_router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=False
|
||||
)
|
||||
16
app/models/__init__.py
Normal file
16
app/models/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Модели данных для приложения.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
from app.models.alertmanager import PrometheusAlert
|
||||
from app.models.grafana import GrafanaAlert, EvalMatch
|
||||
from app.models.zabbix import ZabbixAlert
|
||||
|
||||
__all__ = [
|
||||
"PrometheusAlert",
|
||||
"GrafanaAlert",
|
||||
"EvalMatch",
|
||||
"ZabbixAlert",
|
||||
]
|
||||
69
app/models/alertmanager.py
Normal file
69
app/models/alertmanager.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Модели данных для AlertManager webhooks.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
from typing import Dict, Any, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PrometheusAlert(BaseModel):
|
||||
"""Модель данных вебхука из AlertManager."""
|
||||
status: str = Field(..., description="Статус алерта (firing, resolved)", examples=["firing"])
|
||||
externalURL: str = Field(..., description="Внешний URL AlertManager", examples=["http://alertmanager.example.com"])
|
||||
commonLabels: Dict[str, str] = Field(..., description="Общие метки алерта")
|
||||
commonAnnotations: Dict[str, str] = Field(..., description="Общие аннотации алерта")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"examples": [
|
||||
{
|
||||
"status": "firing",
|
||||
"externalURL": "http://alertmanager.example.com",
|
||||
"commonLabels": {
|
||||
"alertname": "HighCPUUsage",
|
||||
"severity": "critical",
|
||||
"namespace": "production",
|
||||
"pod": "app-deployment-7d8f9b4c5-abc123",
|
||||
"container": "app-container"
|
||||
},
|
||||
"commonAnnotations": {
|
||||
"summary": "High CPU usage detected in production namespace",
|
||||
"description": "CPU usage is above 90% for 5 minutes on pod app-deployment-7d8f9b4c5-abc123",
|
||||
"runbook_url": "https://wiki.example.com/runbooks/high-cpu-usage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": "resolved",
|
||||
"externalURL": "http://alertmanager.example.com",
|
||||
"commonLabels": {
|
||||
"alertname": "HighCPUUsage",
|
||||
"severity": "critical",
|
||||
"namespace": "production",
|
||||
"pod": "app-deployment-7d8f9b4c5-abc123",
|
||||
"container": "app-container"
|
||||
},
|
||||
"commonAnnotations": {
|
||||
"summary": "High CPU usage resolved in production namespace",
|
||||
"description": "CPU usage has returned to normal levels on pod app-deployment-7d8f9b4c5-abc123"
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": "firing",
|
||||
"externalURL": "http://alertmanager.example.com",
|
||||
"commonLabels": {
|
||||
"alertname": "PodCrashLooping",
|
||||
"severity": "warning",
|
||||
"namespace": "staging",
|
||||
"pod": "test-app-5f6g7h8i9-jkl456"
|
||||
},
|
||||
"commonAnnotations": {
|
||||
"summary": "Pod is crash looping",
|
||||
"description": "Pod test-app-5f6g7h8i9-jkl456 has restarted 5 times in the last 10 minutes"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
80
app/models/grafana.py
Normal file
80
app/models/grafana.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Модели данных для Grafana webhooks.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EvalMatch(BaseModel):
|
||||
"""Модель для evalMatches из Grafana."""
|
||||
value: float = Field(..., description="Значение метрики", examples=[95.5, 87.2, 45.2])
|
||||
metric: str = Field(..., description="Название метрики", examples=["cpu_usage_percent", "memory_usage_percent", "disk_usage_percent"])
|
||||
tags: Optional[Dict[str, Any]] = Field(None, description="Теги метрики", examples=[{"host": "server01", "instance": "production"}, None])
|
||||
|
||||
|
||||
class GrafanaAlert(BaseModel):
|
||||
"""Модель данных вебхука из Grafana."""
|
||||
title: str = Field(..., description="Заголовок алерта", examples=["[Alerting] Test notification"])
|
||||
ruleId: int = Field(..., description="ID правила алерта", examples=[674180201771804383])
|
||||
ruleName: str = Field(..., description="Название правила", examples=["Test notification"])
|
||||
state: str = Field(..., description="Состояние алерта (alerting, ok, paused, pending, no_data)", examples=["alerting"])
|
||||
evalMatches: List[EvalMatch] = Field(default_factory=list, description="Совпадения метрик")
|
||||
orgId: int = Field(..., description="ID организации", examples=[0])
|
||||
dashboardId: int = Field(..., description="ID дашборда", examples=[1])
|
||||
panelId: int = Field(..., description="ID панели", examples=[1])
|
||||
tags: Dict[str, str] = Field(default_factory=dict, description="Теги алерта")
|
||||
ruleUrl: str = Field(..., description="URL правила алерта", examples=["http://grafana.cism-ms.ru/"])
|
||||
message: Optional[str] = Field(None, description="Сообщение алерта", examples=["Someone is testing the alert notification within Grafana."])
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"examples": [
|
||||
{
|
||||
"title": "[Alerting] High CPU Usage",
|
||||
"ruleId": 674180201771804383,
|
||||
"ruleName": "High CPU Usage Alert",
|
||||
"state": "alerting",
|
||||
"evalMatches": [
|
||||
{
|
||||
"value": 95.5,
|
||||
"metric": "cpu_usage_percent",
|
||||
"tags": {"host": "server01", "instance": "production"}
|
||||
},
|
||||
{
|
||||
"value": 87.2,
|
||||
"metric": "memory_usage_percent",
|
||||
"tags": {"host": "server01", "instance": "production"}
|
||||
}
|
||||
],
|
||||
"orgId": 1,
|
||||
"dashboardId": 123,
|
||||
"panelId": 456,
|
||||
"tags": {"severity": "critical", "environment": "production"},
|
||||
"ruleUrl": "http://grafana.cism-ms.ru/alerting/list",
|
||||
"message": "CPU usage is above 90% threshold for more than 5 minutes"
|
||||
},
|
||||
{
|
||||
"title": "[OK] High CPU Usage",
|
||||
"ruleId": 674180201771804383,
|
||||
"ruleName": "High CPU Usage Alert",
|
||||
"state": "ok",
|
||||
"evalMatches": [
|
||||
{
|
||||
"value": 45.2,
|
||||
"metric": "cpu_usage_percent",
|
||||
"tags": {"host": "server01", "instance": "production"}
|
||||
}
|
||||
],
|
||||
"orgId": 1,
|
||||
"dashboardId": 123,
|
||||
"panelId": 456,
|
||||
"tags": {"severity": "critical", "environment": "production"},
|
||||
"ruleUrl": "http://grafana.cism-ms.ru/alerting/list",
|
||||
"message": "CPU usage has returned to normal levels"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
106
app/models/group.py
Normal file
106
app/models/group.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Модели данных для управления группами мессенджеров.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class GroupInfo(BaseModel):
|
||||
"""Информация о группе."""
|
||||
name: str = Field(..., description="Имя группы", examples=["monitoring", "alerts", "devops"])
|
||||
messenger: Optional[str] = Field(None, description="Тип мессенджера (telegram, max)", examples=["telegram", "max"])
|
||||
chat_id: Optional[Union[int, str]] = Field(None, description="Chat ID группы (отображается только при наличии пароля)", examples=[-1001234567890, "123456789", None])
|
||||
thread_id: Optional[int] = Field(None, description="ID треда в группе (опционально, только для Telegram)", examples=[0, 123, 456])
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"examples": [
|
||||
{
|
||||
"name": "monitoring",
|
||||
"messenger": "telegram",
|
||||
"chat_id": -1001234567890,
|
||||
"thread_id": 0
|
||||
},
|
||||
{
|
||||
"name": "alerts_max",
|
||||
"messenger": "max",
|
||||
"chat_id": "123456789",
|
||||
"thread_id": None
|
||||
},
|
||||
{
|
||||
"name": "devops",
|
||||
"messenger": None,
|
||||
"chat_id": None,
|
||||
"thread_id": None
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class CreateGroupRequest(BaseModel):
|
||||
"""Запрос на создание группы."""
|
||||
group_name: str = Field(..., description="Имя группы", examples=["monitoring", "alerts", "devops"])
|
||||
chat_id: Union[int, str] = Field(..., description="ID чата (может быть int для Telegram или str для MAX/VK)", examples=[-1001234567890, "123456789"])
|
||||
messenger: str = Field("telegram", description="Тип мессенджера (telegram, max)", examples=["telegram", "max"])
|
||||
thread_id: int = Field(0, description="ID треда в группе (по умолчанию 0, только для Telegram)", examples=[0, 123, 456])
|
||||
config: Optional[Dict[str, Any]] = Field(None, description="Дополнительная конфигурация для мессенджера (опционально)", examples=[None, {"access_token": "..."}, {"api_version": "5.131"}])
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"examples": [
|
||||
{
|
||||
"group_name": "monitoring",
|
||||
"chat_id": -1001234567890,
|
||||
"messenger": "telegram",
|
||||
"thread_id": 0,
|
||||
},
|
||||
{
|
||||
"group_name": "alerts_max",
|
||||
"chat_id": "123456789",
|
||||
"messenger": "max",
|
||||
"thread_id": 0,
|
||||
"config": {
|
||||
"access_token": "your_access_token",
|
||||
"api_version": "5.131"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class UpdateGroupRequest(BaseModel):
|
||||
"""Запрос на обновление группы."""
|
||||
chat_id: Optional[Union[int, str]] = Field(None, description="Новый Chat ID группы (можно получить через @userinfobot для Telegram)", examples=[-1001234567891, "123456789"])
|
||||
messenger: Optional[str] = Field(None, description="Новый тип мессенджера (telegram, max)", examples=["telegram", "max"])
|
||||
thread_id: Optional[int] = Field(None, description="Новый ID треда в группе (опционально, только для Telegram)", examples=[0, 123, 456])
|
||||
config: Optional[Dict[str, Any]] = Field(None, description="Новая дополнительная конфигурация для мессенджера (опционально)", examples=[None, {"access_token": "..."}, {"api_version": "5.131"}])
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"examples": [
|
||||
{
|
||||
"chat_id": -1001234567891,
|
||||
"messenger": "telegram",
|
||||
"thread_id": 0,
|
||||
},
|
||||
{
|
||||
"chat_id": "123456789",
|
||||
"messenger": "max",
|
||||
"config": {
|
||||
"access_token": "your_access_token",
|
||||
"api_version": "5.131"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class DeleteGroupRequest(BaseModel):
|
||||
"""Запрос на удаление группы."""
|
||||
pass
|
||||
73
app/models/jira.py
Normal file
73
app/models/jira.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Модели данных для Jira тикетов.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
from typing import Optional, Dict, Any, List
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class JiraMappingCondition(BaseModel):
|
||||
"""Условия для маппинга алерта в Jira тикет."""
|
||||
severity: Optional[str] = None
|
||||
namespace: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
tags: Optional[Dict[str, str]] = None
|
||||
event_severity: Optional[str] = Field(None, alias="event-severity")
|
||||
|
||||
|
||||
class JiraMapping(BaseModel):
|
||||
"""Маппинг алерта в Jira тикет."""
|
||||
conditions: JiraMappingCondition
|
||||
project: str = Field(..., description="Ключ проекта Jira")
|
||||
assignee: Optional[str] = Field(None, description="Email исполнителя")
|
||||
issue_type: str = Field("Bug", description="Тип задачи")
|
||||
priority: str = Field("High", description="Приоритет задачи")
|
||||
labels: Optional[List[str]] = Field(None, description="Метки задачи")
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class JiraSourceMapping(BaseModel):
|
||||
"""Маппинг для источника алертов."""
|
||||
default_project: str = Field(..., description="Проект по умолчанию")
|
||||
default_assignee: Optional[str] = Field(None, description="Исполнитель по умолчанию")
|
||||
default_issue_type: str = Field("Bug", description="Тип задачи по умолчанию")
|
||||
default_priority: str = Field("High", description="Приоритет по умолчанию")
|
||||
mappings: List[JiraMapping] = Field(default_factory=list, description="Список маппингов")
|
||||
|
||||
|
||||
class JiraMappingConfig(BaseModel):
|
||||
"""Конфигурация маппинга алертов в Jira тикеты."""
|
||||
alertmanager: Optional[JiraSourceMapping] = None
|
||||
grafana: Optional[JiraSourceMapping] = None
|
||||
zabbix: Optional[JiraSourceMapping] = None
|
||||
|
||||
|
||||
class JiraIssue(BaseModel):
|
||||
"""Модель Jira тикета."""
|
||||
project: str = Field(..., description="Ключ проекта")
|
||||
summary: str = Field(..., description="Заголовок тикета")
|
||||
description: str = Field(..., description="Описание тикета")
|
||||
issue_type: str = Field("Bug", description="Тип задачи")
|
||||
assignee: Optional[str] = Field(None, description="Email исполнителя")
|
||||
priority: Optional[str] = Field(None, description="Приоритет задачи")
|
||||
labels: Optional[List[str]] = Field(None, description="Метки задачи")
|
||||
components: Optional[List[str]] = Field(None, description="Компоненты проекта")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"examples": [{
|
||||
"project": "MON",
|
||||
"summary": "[Critical] High CPU Usage - Production",
|
||||
"description": "**Alert:** High CPU Usage\n\n**Severity:** Critical\n\n**Namespace:** production",
|
||||
"issue_type": "Bug",
|
||||
"assignee": "devops-team@example.com",
|
||||
"priority": "Highest",
|
||||
"labels": ["critical", "production", "alertmanager"]
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
195
app/models/message.py
Normal file
195
app/models/message.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Модели данных для отправки простых сообщений в Telegram.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
"""Запрос на отправку текстового сообщения."""
|
||||
tg_group: str = Field(..., description="Имя группы Telegram из конфигурации", examples=["monitoring", "alerts", "devops"])
|
||||
tg_thread_id: int = Field(0, description="ID треда в группе Telegram (0 для основной группы)", examples=[0, 123, 456])
|
||||
text: str = Field(..., description="Текст сообщения", examples=["Привет! Это тестовое сообщение.", "<b>Важное уведомление</b>\n\nСистема работает нормально."])
|
||||
parse_mode: Optional[str] = Field("HTML", description="Режим парсинга (HTML, Markdown, MarkdownV2)", examples=["HTML", "Markdown", "MarkdownV2"])
|
||||
disable_web_page_preview: bool = Field(True, description="Отключить превью ссылок", examples=[True, False])
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"examples": [
|
||||
{
|
||||
"tg_group": "monitoring",
|
||||
"tg_thread_id": 0,
|
||||
"text": "Привет! Это тестовое сообщение.",
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True
|
||||
},
|
||||
{
|
||||
"tg_group": "alerts",
|
||||
"tg_thread_id": 123,
|
||||
"text": "<b>Критическое уведомление</b>\n\nСистема недоступна!\n\n<i>Время: 2024-02-08 16:49:44</i>",
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": False
|
||||
},
|
||||
{
|
||||
"tg_group": "monitoring",
|
||||
"tg_thread_id": 0,
|
||||
"text": "**Важное уведомление**\n\nСистема работает нормально.\n\n[Подробнее](https://example.com)",
|
||||
"parse_mode": "Markdown",
|
||||
"disable_web_page_preview": True
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SendPhotoRequest(BaseModel):
|
||||
"""Запрос на отправку фото."""
|
||||
tg_group: str = Field(..., description="Имя группы Telegram из конфигурации", examples=["monitoring", "alerts"])
|
||||
tg_thread_id: int = Field(0, description="ID треда в группе Telegram (0 для основной группы)", examples=[0, 123])
|
||||
photo: str = Field(..., description="URL фото или путь к файлу (поддерживается автоматическая загрузка с URL)", examples=["https://example.com/image.jpg", "https://grafana.example.com/render/dashboard-solo?panelId=1&width=1000&height=500"])
|
||||
caption: Optional[str] = Field(None, description="Подпись к фото", examples=["График производительности", "<b>График CPU</b>\n\nВремя: 2024-02-08 16:49:44"])
|
||||
parse_mode: Optional[str] = Field("HTML", description="Режим парсинга подписи (HTML, Markdown, MarkdownV2)", examples=["HTML", "Markdown"])
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"examples": [
|
||||
{
|
||||
"tg_group": "monitoring",
|
||||
"tg_thread_id": 0,
|
||||
"photo": "https://example.com/image.jpg",
|
||||
"caption": "Описание фото",
|
||||
"parse_mode": "HTML"
|
||||
},
|
||||
{
|
||||
"tg_group": "alerts",
|
||||
"tg_thread_id": 123,
|
||||
"photo": "https://grafana.example.com/render/dashboard-solo?panelId=1&width=1000&height=500",
|
||||
"caption": "<b>График CPU</b>\n\n<i>Время: 2024-02-08 16:49:44</i>",
|
||||
"parse_mode": "HTML"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SendVideoRequest(BaseModel):
|
||||
"""Запрос на отправку видео."""
|
||||
tg_group: str = Field(..., description="Имя группы Telegram из конфигурации", examples=["monitoring", "alerts"])
|
||||
tg_thread_id: int = Field(0, description="ID треда в группе Telegram (0 для основной группы)", examples=[0, 123])
|
||||
video: str = Field(..., description="URL видео или путь к файлу (поддерживается автоматическая загрузка с URL)", examples=["https://example.com/video.mp4", "https://example.com/recording.webm"])
|
||||
caption: Optional[str] = Field(None, description="Подпись к видео", examples=["Запись экрана", "<b>Запись работы системы</b>\n\nДлительность: 60 сек"])
|
||||
parse_mode: Optional[str] = Field("HTML", description="Режим парсинга подписи (HTML, Markdown, MarkdownV2)", examples=["HTML", "Markdown"])
|
||||
duration: Optional[int] = Field(None, description="Длительность видео в секундах", examples=[60, 120, 300])
|
||||
width: Optional[int] = Field(None, description="Ширина видео в пикселях", examples=[1280, 1920])
|
||||
height: Optional[int] = Field(None, description="Высота видео в пикселях", examples=[720, 1080])
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"examples": [
|
||||
{
|
||||
"tg_group": "monitoring",
|
||||
"tg_thread_id": 0,
|
||||
"video": "https://example.com/video.mp4",
|
||||
"caption": "Описание видео",
|
||||
"parse_mode": "HTML",
|
||||
"duration": 60,
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
},
|
||||
{
|
||||
"tg_group": "alerts",
|
||||
"tg_thread_id": 123,
|
||||
"video": "https://example.com/recording.webm",
|
||||
"caption": "<b>Запись работы системы</b>\n\n<i>Длительность: 60 сек</i>",
|
||||
"parse_mode": "HTML",
|
||||
"duration": 60,
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SendAudioRequest(BaseModel):
|
||||
"""Запрос на отправку аудио."""
|
||||
tg_group: str = Field(..., description="Имя группы Telegram из конфигурации", examples=["monitoring", "alerts"])
|
||||
tg_thread_id: int = Field(0, description="ID треда в группе Telegram (0 для основной группы)", examples=[0, 123])
|
||||
audio: str = Field(..., description="URL аудио файла или путь к файлу (поддерживается автоматическая загрузка с URL)", examples=["https://example.com/audio.mp3", "https://example.com/notification.ogg"])
|
||||
caption: Optional[str] = Field(None, description="Подпись к аудио", examples=["Аудио уведомление", "<b>Аудио запись</b>\n\nДлительность: 3 мин"])
|
||||
parse_mode: Optional[str] = Field("HTML", description="Режим парсинга подписи (HTML, Markdown, MarkdownV2)", examples=["HTML", "Markdown"])
|
||||
duration: Optional[int] = Field(None, description="Длительность аудио в секундах", examples=[180, 300])
|
||||
performer: Optional[str] = Field(None, description="Исполнитель (для музыкальных файлов)", examples=["Artist Name", "System Notification"])
|
||||
title: Optional[str] = Field(None, description="Название трека (для музыкальных файлов)", examples=["Song Title", "Alert Notification"])
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"examples": [
|
||||
{
|
||||
"tg_group": "monitoring",
|
||||
"tg_thread_id": 0,
|
||||
"audio": "https://example.com/audio.mp3",
|
||||
"caption": "Описание аудио",
|
||||
"parse_mode": "HTML",
|
||||
"duration": 180,
|
||||
"performer": "Artist Name",
|
||||
"title": "Song Title"
|
||||
},
|
||||
{
|
||||
"tg_group": "alerts",
|
||||
"tg_thread_id": 123,
|
||||
"audio": "https://example.com/notification.ogg",
|
||||
"caption": "<b>Аудио уведомление</b>\n\n<i>Система работает нормально</i>",
|
||||
"parse_mode": "HTML",
|
||||
"duration": 30,
|
||||
"performer": "System Notification",
|
||||
"title": "Alert Notification"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SendDocumentRequest(BaseModel):
|
||||
"""Запрос на отправку документа."""
|
||||
tg_group: str = Field(..., description="Имя группы Telegram из конфигурации", examples=["monitoring", "alerts"])
|
||||
tg_thread_id: int = Field(0, description="ID треда в группе Telegram (0 для основной группы)", examples=[0, 123])
|
||||
document: str = Field(..., description="URL документа или путь к файлу (поддерживается автоматическая загрузка с URL)", examples=["https://example.com/file.pdf", "https://example.com/report.xlsx"])
|
||||
caption: Optional[str] = Field(None, description="Подпись к документу", examples=["Отчет за неделю", "<b>Отчет</b>\n\nДата: 2024-02-08"])
|
||||
parse_mode: Optional[str] = Field("HTML", description="Режим парсинга подписи (HTML, Markdown, MarkdownV2)", examples=["HTML", "Markdown"])
|
||||
filename: Optional[str] = Field(None, description="Имя файла (если не указано, используется имя из URL)", examples=["document.pdf", "report_2024-02-08.xlsx"])
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"examples": [
|
||||
{
|
||||
"tg_group": "monitoring",
|
||||
"tg_thread_id": 0,
|
||||
"document": "https://example.com/file.pdf",
|
||||
"caption": "Описание документа",
|
||||
"parse_mode": "HTML",
|
||||
"filename": "document.pdf"
|
||||
},
|
||||
{
|
||||
"tg_group": "alerts",
|
||||
"tg_thread_id": 123,
|
||||
"document": "https://example.com/report.xlsx",
|
||||
"caption": "<b>Отчет за неделю</b>\n\n<i>Дата: 2024-02-08</i>",
|
||||
"parse_mode": "HTML",
|
||||
"filename": "report_2024-02-08.xlsx"
|
||||
},
|
||||
{
|
||||
"tg_group": "monitoring",
|
||||
"tg_thread_id": 0,
|
||||
"document": "https://example.com/logs.txt",
|
||||
"caption": "Логи системы",
|
||||
"parse_mode": "HTML",
|
||||
"filename": "system_logs_2024-02-08.txt"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
81
app/models/zabbix.py
Normal file
81
app/models/zabbix.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Модели данных для Zabbix webhooks.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ZabbixAlert(BaseModel):
|
||||
"""Модель данных вебхука из Zabbix."""
|
||||
link: str = Field(..., description="Ссылка на событие в Zabbix", examples=["{$ZABBIX_URL}/tr_events.php?triggerid=42667&eventid=8819711"])
|
||||
status: str = Field(..., description="Статус события (OK, PROBLEM)", examples=["OK"])
|
||||
action_id: str = Field(..., alias="action-id", description="ID действия", examples=["7"])
|
||||
alert_subject: str = Field(..., alias="alert-subject", description="Тема алерта", examples=["Resolved in 1m 0s: High CPU utilization (over 90% for 5m)"])
|
||||
alert_message: str = Field(..., alias="alert-message", description="Сообщение алерта", examples=["Problem has been resolved at 16:49:44 on 2024.02.08"])
|
||||
event_id: str = Field(..., alias="event-id", description="ID события", examples=["8819711"])
|
||||
event_name: str = Field(..., alias="event-name", description="Название события", examples=["High CPU utilization (over 90% for 5m)"])
|
||||
event_nseverity: str = Field(..., alias="event-nseverity", description="Числовой уровень серьезности", examples=["2"])
|
||||
event_opdata: Optional[str] = Field(None, alias="event-opdata", description="Операционные данные события", examples=["Current utilization: 70.9 %"])
|
||||
event_recovery_date: Optional[str] = Field(None, alias="event-recovery-date", description="Дата восстановления", examples=["2024.02.08"])
|
||||
event_recovery_time: Optional[str] = Field(None, alias="event-recovery-time", description="Время восстановления", examples=["16:49:44"])
|
||||
event_duration: Optional[str] = Field(None, alias="event-duration", description="Длительность события", examples=["1m 0s"])
|
||||
event_recovery_name: Optional[str] = Field(None, alias="event-recovery-name", description="Название восстановленного события", examples=["High CPU utilization (over 90% for 5m)"])
|
||||
event_recovery_status: Optional[str] = Field(None, alias="event-recovery-status", description="Статус восстановления", examples=["RESOLVED"])
|
||||
event_recovery_tags: Optional[str] = Field(None, alias="event-recovery-tags", description="Теги восстановленного события", examples=["Application:CPU"])
|
||||
event_severity: Optional[str] = Field(None, alias="event-severity", description="Уровень серьезности (Disaster, High, Warning, Average, Information)", examples=["Warning"])
|
||||
host_name: str = Field(..., alias="host-name", description="Имя хоста", examples=["pnode28"])
|
||||
host_ip: str = Field(..., alias="host-ip", description="IP адрес хоста", examples=["10.14.253.38"])
|
||||
host_port: str = Field(..., alias="host-port", description="Порт хоста", examples=["10050"])
|
||||
|
||||
model_config = {
|
||||
"populate_by_name": True,
|
||||
"json_schema_extra": {
|
||||
"examples": [
|
||||
{
|
||||
"link": "https://zabbix.example.com/tr_events.php?triggerid=42667&eventid=8819711",
|
||||
"status": "PROBLEM",
|
||||
"action-id": "7",
|
||||
"alert-subject": "Problem: High CPU utilization (over 90% for 5m)",
|
||||
"alert-message": "Problem started at 16:48:44 on 2024.02.08\r\nProblem name: High CPU utilization (over 90% for 5m)\r\nHost: pnode28\r\nSeverity: Warning\r\nCurrent utilization: 95.2 %\r\n",
|
||||
"event-id": "8819711",
|
||||
"event-name": "High CPU utilization (over 90% for 5m)",
|
||||
"event-nseverity": "2",
|
||||
"event-opdata": "Current utilization: 95.2 %",
|
||||
"event-recovery-date": None,
|
||||
"event-recovery-time": None,
|
||||
"event-duration": None,
|
||||
"event-recovery-name": None,
|
||||
"event-recovery-status": None,
|
||||
"event-recovery-tags": None,
|
||||
"event-severity": "Warning",
|
||||
"host-name": "pnode28",
|
||||
"host-ip": "10.14.253.38",
|
||||
"host-port": "10050"
|
||||
},
|
||||
{
|
||||
"link": "https://zabbix.example.com/tr_events.php?triggerid=42667&eventid=8819711",
|
||||
"status": "OK",
|
||||
"action-id": "7",
|
||||
"alert-subject": "Resolved in 1m 0s: High CPU utilization (over 90% for 5m)",
|
||||
"alert-message": "Problem has been resolved at 16:49:44 on 2024.02.08\r\nProblem name: High CPU utilization (over 90% for 5m)\r\nProblem duration: 1m 0s\r\nHost: pnode28\r\nSeverity: Warning\r\nOriginal problem ID: 8819711\r\n",
|
||||
"event-id": "8819711",
|
||||
"event-name": "High CPU utilization (over 90% for 5m)",
|
||||
"event-nseverity": "2",
|
||||
"event-opdata": "Current utilization: 70.9 %",
|
||||
"event-recovery-date": "2024.02.08",
|
||||
"event-recovery-time": "16:49:44",
|
||||
"event-duration": "1m 0s",
|
||||
"event-recovery-name": "High CPU utilization (over 90% for 5m)",
|
||||
"event-recovery-status": "RESOLVED",
|
||||
"event-recovery-tags": "Application:CPU",
|
||||
"event-severity": "Warning",
|
||||
"host-name": "pnode28",
|
||||
"host-ip": "10.14.253.38",
|
||||
"host-port": "10050"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
0
app/modules/__init__.py
Normal file
0
app/modules/__init__.py
Normal file
311
app/modules/alertmanager.py
Normal file
311
app/modules/alertmanager.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
Модуль для обработки алертов из AlertManager.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from typing import Tuple, Optional
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
from app.models.alertmanager import PrometheusAlert
|
||||
from app.core.groups import groups_config
|
||||
from app.core.metrics import metrics
|
||||
from app.core.jira_utils import create_jira_ticket_from_alert
|
||||
from app.core.jira_mapping import jira_mapping_manager
|
||||
from app.core.utils import check_stop_words, add_spaces_to_alert_name, truncate_message
|
||||
from app.core.messenger_factory import MessengerFactory
|
||||
from app.core.button_utils import convert_telegram_buttons_to_dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_status_icons(status: str, severity: Optional[str]) -> Tuple[str, str, str]:
|
||||
"""
|
||||
Получить иконки и название статуса в зависимости от статуса и серьезности алерта.
|
||||
|
||||
Args:
|
||||
status: Статус алерта (firing, resolved, critical).
|
||||
severity: Уровень серьезности.
|
||||
|
||||
Returns:
|
||||
Кортеж (alert_icon, status_icon, status_name).
|
||||
"""
|
||||
if status == "critical" or severity == "critical":
|
||||
return ("🔴", "💀", "Бросаем все и чиним")
|
||||
elif status == "firing" or severity == "firing" or severity == "warning":
|
||||
return ("🟡", "🔥", "Что-то сломалось")
|
||||
elif status == "resolved":
|
||||
return ("🟢", "✅", "Работает")
|
||||
else:
|
||||
return ("🔸", "ℹ️", status)
|
||||
|
||||
|
||||
async def send(
|
||||
k8s_cluster: str,
|
||||
group_name: str,
|
||||
thread_id: int,
|
||||
alert: PrometheusAlert,
|
||||
messenger: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Отправить алерт из AlertManager в мессенджер.
|
||||
|
||||
Args:
|
||||
k8s_cluster: Имя Kubernetes кластера.
|
||||
group_name: Имя группы из конфигурации.
|
||||
thread_id: ID треда в группе (0 для основной группы).
|
||||
alert: Данные алерта из AlertManager.
|
||||
messenger: Тип мессенджера (опционально, если не указан - используется из конфигурации группы).
|
||||
|
||||
Raises:
|
||||
ValueError: Если группа не найдена в конфигурации или алерт заблокирован стоп-словами.
|
||||
"""
|
||||
source = "alertmanager"
|
||||
|
||||
# Увеличиваем счетчик полученных сообщений
|
||||
metrics.increment_total_message(source, k8s_cluster, group_name, thread_id)
|
||||
|
||||
# Проверяем стоп-слова
|
||||
alert_name = alert.commonLabels.get("alertname", "")
|
||||
if alert_name and check_stop_words(alert_name):
|
||||
logger.info(f"Алерт '{alert_name}' заблокирован стоп-словами")
|
||||
metrics.increment_reject_message(source, k8s_cluster, group_name, thread_id)
|
||||
return # Не отправляем сообщение
|
||||
|
||||
# Получаем конфигурацию группы
|
||||
group_config = await groups_config.get_group_config(group_name, messenger)
|
||||
if group_config is None:
|
||||
raise ValueError(f"Группа '{group_name}' не найдена в конфигурации")
|
||||
|
||||
messenger_type = group_config.get("messenger", "telegram")
|
||||
chat_id = group_config.get("chat_id")
|
||||
group_thread_id = group_config.get("thread_id", 0)
|
||||
|
||||
# Используем thread_id из параметра, если указан, иначе из конфигурации группы
|
||||
final_thread_id = thread_id if thread_id > 0 else group_thread_id
|
||||
|
||||
# Создаем клиент мессенджера
|
||||
messenger_client = MessengerFactory.create_from_config(group_config)
|
||||
|
||||
# Проверяем поддержку тредов
|
||||
if not messenger_client.supports_threads and final_thread_id > 0:
|
||||
logger.warning(f"Мессенджер '{messenger_type}' не поддерживает треды, thread_id будет проигнорирован")
|
||||
final_thread_id = None
|
||||
elif final_thread_id == 0:
|
||||
final_thread_id = None
|
||||
|
||||
# Формируем сообщение
|
||||
message, buttons = render_message(k8s_cluster, group_name, thread_id, alert)
|
||||
|
||||
# Обрезаем сообщение если оно слишком длинное
|
||||
message = truncate_message(message)
|
||||
|
||||
# Создаем Jira тикет, если включено и нужно
|
||||
jira_issue_key = None
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
if settings.jira_enabled:
|
||||
should_create_ticket = (
|
||||
(settings.jira_create_on_alert and alert.status != "resolved") or
|
||||
(settings.jira_create_on_resolved and alert.status == "resolved")
|
||||
)
|
||||
|
||||
if should_create_ticket:
|
||||
try:
|
||||
jira_issue_key = await create_jira_ticket_from_alert(
|
||||
alert=alert,
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster
|
||||
)
|
||||
if jira_issue_key:
|
||||
# Добавляем кнопку Jira в сообщение
|
||||
jira_button = _create_jira_button(jira_issue_key, settings)
|
||||
if jira_button:
|
||||
if buttons:
|
||||
# Добавляем кнопку к существующим кнопкам
|
||||
new_buttons = buttons.inline_keyboard.copy()
|
||||
new_buttons.append([jira_button])
|
||||
buttons = InlineKeyboardMarkup(new_buttons)
|
||||
else:
|
||||
# Если кнопок еще нет, создаем новую клавиатуру
|
||||
buttons = InlineKeyboardMarkup([[jira_button]])
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Jira тикета: {e}")
|
||||
|
||||
# Преобразуем кнопки в универсальный формат
|
||||
buttons_dict = convert_telegram_buttons_to_dict(buttons)
|
||||
|
||||
# Отправляем сообщение
|
||||
success = await messenger_client.send_message(
|
||||
chat_id=chat_id,
|
||||
text=message,
|
||||
thread_id=final_thread_id,
|
||||
reply_markup=buttons_dict,
|
||||
disable_web_page_preview=True,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
if success:
|
||||
metrics.increment_sent_message(source, k8s_cluster, group_name, thread_id)
|
||||
|
||||
# Увеличиваем счетчики в зависимости от статуса
|
||||
severity = alert.commonLabels.get("severity", "")
|
||||
if alert.status == "firing" or severity in ["firing", "warning"]:
|
||||
metrics.increment_firing_message(source, k8s_cluster, group_name, thread_id)
|
||||
elif alert.status == "critical" or severity == "critical":
|
||||
metrics.increment_critical_message(source, k8s_cluster, group_name, thread_id)
|
||||
elif alert.status == "resolved":
|
||||
metrics.increment_resolved_message(source, k8s_cluster, group_name, thread_id)
|
||||
else:
|
||||
metrics.increment_error_message(source, k8s_cluster, group_name, thread_id)
|
||||
raise Exception(f"Ошибка отправки сообщения в {group_config.get('messenger', 'unknown')}")
|
||||
|
||||
|
||||
def _create_jira_button(issue_key: str, settings) -> Optional[InlineKeyboardButton]:
|
||||
"""
|
||||
Создать кнопку для ссылки на Jira тикет.
|
||||
|
||||
Args:
|
||||
issue_key: Ключ тикета Jira (например, "MON-123").
|
||||
settings: Настройки приложения.
|
||||
|
||||
Returns:
|
||||
InlineKeyboardButton с ссылкой на тикет или None.
|
||||
"""
|
||||
if not issue_key or not settings.jira_url:
|
||||
return None
|
||||
|
||||
jira_url = f"{settings.jira_url}/browse/{issue_key}"
|
||||
return InlineKeyboardButton(f"📋 Jira: {issue_key}", url=jira_url)
|
||||
|
||||
|
||||
def render_message(
|
||||
k8s_cluster: str,
|
||||
group_name: str,
|
||||
thread_id: int,
|
||||
alert: PrometheusAlert
|
||||
) -> Tuple[str, Optional[InlineKeyboardMarkup]]:
|
||||
"""
|
||||
Сформировать сообщение и кнопки для мессенджера из алерта AlertManager.
|
||||
|
||||
Args:
|
||||
k8s_cluster: Имя Kubernetes кластера.
|
||||
group_name: Имя группы.
|
||||
thread_id: ID треда в группе.
|
||||
alert: Данные алерта из AlertManager.
|
||||
|
||||
Returns:
|
||||
Кортеж (message, buttons).
|
||||
"""
|
||||
message_dict = {}
|
||||
|
||||
# Обрабатываем аннотации
|
||||
another_annotations = ""
|
||||
runbook_url = ""
|
||||
for key, value in alert.commonAnnotations.items():
|
||||
if key == "summary":
|
||||
message_dict['summary'] = value.rstrip()
|
||||
elif key == "description":
|
||||
message_dict['description'] = value.rstrip()
|
||||
elif key == "runbook_url":
|
||||
message_dict['runbook_url'] = value
|
||||
runbook_url = value
|
||||
else:
|
||||
another_annotations += f"<b>{key}</b>: {value}\n"
|
||||
message_dict['another_annotations'] = another_annotations
|
||||
|
||||
# Обрабатываем метки
|
||||
another_labels = ""
|
||||
severity = ""
|
||||
alertname = ""
|
||||
|
||||
for key, value in alert.commonLabels.items():
|
||||
if key == "alertname":
|
||||
alertname = add_spaces_to_alert_name(value)
|
||||
message_dict['alertname'] = alertname
|
||||
elif key == "severity":
|
||||
message_dict['severity'] = value
|
||||
severity = value
|
||||
elif key in [
|
||||
"daemonset", "statefulset", "replicaset", "job_name", "To", "integration",
|
||||
"condition", "reason", "alertstate", "clustername", "namespace", "node",
|
||||
"persistentvolumeclaim", "service", "container", "endpoint", "instance",
|
||||
"job", "prometheus", "pod", "deployment", "metrics_path", "grpc_method",
|
||||
"grpc_service", "uid"
|
||||
]:
|
||||
# Маппинг ключей для шаблона
|
||||
if key == "namespace":
|
||||
message_dict['ns'] = value
|
||||
else:
|
||||
message_dict[key] = value
|
||||
else:
|
||||
another_labels += f"{key}: {value}\n"
|
||||
|
||||
message_dict['another_labels'] = another_labels
|
||||
|
||||
# Получаем иконки статуса
|
||||
alert_icon, status_icon, status_name = _get_status_icons(alert.status, severity)
|
||||
message_dict['alert_icon'] = alert_icon
|
||||
message_dict['status_icon'] = status_icon
|
||||
message_dict['status_name'] = status_name
|
||||
|
||||
# Рендерим шаблон
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
environment = Environment(loader=FileSystemLoader(settings.templates_path))
|
||||
template = environment.get_template("alertmanager.tmpl")
|
||||
message = template.render(message_dict)
|
||||
|
||||
# Формируем кнопки
|
||||
buttons = render_buttons(k8s_cluster, runbook_url, alert.status)
|
||||
|
||||
logger.info("Сообщение AlertManager сформировано")
|
||||
return message, buttons
|
||||
|
||||
|
||||
def render_buttons(
|
||||
k8s_cluster: str,
|
||||
runbook_url: str,
|
||||
alert_status: str
|
||||
) -> Optional[InlineKeyboardMarkup]:
|
||||
"""
|
||||
Сформировать кнопки для сообщения мессенджера.
|
||||
|
||||
Args:
|
||||
k8s_cluster: Имя Kubernetes кластера.
|
||||
runbook_url: URL runbook с решением проблемы.
|
||||
alert_status: Статус алерта.
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup с кнопками или None.
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
buttons = []
|
||||
|
||||
try:
|
||||
# Кнопки для мониторинга Kubernetes
|
||||
grafana_url = settings.get_k8s_grafana_url(k8s_cluster)
|
||||
prometheus_url = settings.get_k8s_prometheus_url(k8s_cluster)
|
||||
alertmanager_url = settings.get_k8s_alertmanager_url(k8s_cluster)
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton("Grafana", url=grafana_url),
|
||||
InlineKeyboardButton("Prometheus", url=prometheus_url),
|
||||
InlineKeyboardButton("Alertmanager", url=alertmanager_url)
|
||||
])
|
||||
except ValueError as e:
|
||||
logger.warning(f"Не удалось сформировать URL для Kubernetes: {e}")
|
||||
|
||||
# Кнопка runbook (только для активных алертов)
|
||||
if runbook_url and alert_status != "resolved":
|
||||
buttons.append([InlineKeyboardButton("Вариант решения проблемы...", url=runbook_url)])
|
||||
|
||||
markup = InlineKeyboardMarkup(buttons) if buttons else None
|
||||
logger.debug("Кнопки AlertManager сгенерированы")
|
||||
return markup
|
||||
261
app/modules/grafana.py
Normal file
261
app/modules/grafana.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Модуль для обработки алертов из Grafana.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from typing import Tuple, Optional, Dict, Any
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
from app.models.grafana import GrafanaAlert
|
||||
from app.core.groups import groups_config
|
||||
from app.core.metrics import metrics
|
||||
from app.core.jira_utils import create_jira_ticket_from_alert
|
||||
from app.core.utils import truncate_message
|
||||
from app.core.messenger_factory import MessengerFactory
|
||||
from app.core.button_utils import convert_telegram_buttons_to_dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_status_icons(state: str) -> Tuple[str, str, str]:
|
||||
"""
|
||||
Получить иконки и название статуса в зависимости от состояния алерта.
|
||||
|
||||
Args:
|
||||
state: Состояние алерта из Grafana.
|
||||
|
||||
Returns:
|
||||
Кортеж (alert_icon, status_icon, status_name).
|
||||
"""
|
||||
status_map = {
|
||||
"alerting": ("🔴", "💀", "Бросаем все и чиним"),
|
||||
"ok": ("🟢", "✅", "Заработало"),
|
||||
"paused": ("🟡", "🐢", "Пауза? Серьезно?!"),
|
||||
"pending": ("🟠", "🤷", "Что-то начинается..."),
|
||||
"no_data": ("🔵", "🥴", "Данные куда-то пропали"),
|
||||
}
|
||||
|
||||
return status_map.get(state, ("🔸", "ℹ️", state))
|
||||
|
||||
|
||||
async def send(group_name: str, thread_id: int, alert: GrafanaAlert, messenger: Optional[str] = None) -> None:
|
||||
"""
|
||||
Отправить алерт из Grafana в мессенджер.
|
||||
|
||||
Args:
|
||||
group_name: Имя группы из конфигурации.
|
||||
thread_id: ID треда в группе (0 для основной группы).
|
||||
alert: Данные алерта из Grafana.
|
||||
messenger: Тип мессенджера (опционально, если не указан - используется из конфигурации группы).
|
||||
|
||||
Raises:
|
||||
ValueError: Если группа не найдена в конфигурации.
|
||||
"""
|
||||
source = "grafana"
|
||||
k8s_cluster = ""
|
||||
|
||||
# Увеличиваем счетчик полученных сообщений
|
||||
metrics.increment_total_message(source, k8s_cluster, group_name, thread_id)
|
||||
|
||||
# Получаем конфигурацию группы
|
||||
group_config = await groups_config.get_group_config(group_name, messenger)
|
||||
if group_config is None:
|
||||
raise ValueError(f"Группа '{group_name}' не найдена в конфигурации")
|
||||
|
||||
messenger_type = group_config.get("messenger", "telegram")
|
||||
chat_id = group_config.get("chat_id")
|
||||
group_thread_id = group_config.get("thread_id", 0)
|
||||
|
||||
# Используем thread_id из параметра, если указан, иначе из конфигурации группы
|
||||
final_thread_id = thread_id if thread_id > 0 else group_thread_id
|
||||
|
||||
# Создаем клиент мессенджера
|
||||
messenger_client = MessengerFactory.create_from_config(group_config)
|
||||
|
||||
# Проверяем поддержку тредов
|
||||
if not messenger_client.supports_threads and final_thread_id > 0:
|
||||
logger.warning(f"Мессенджер '{messenger_type}' не поддерживает треды, thread_id будет проигнорирован")
|
||||
final_thread_id = None
|
||||
elif final_thread_id == 0:
|
||||
final_thread_id = None
|
||||
|
||||
# Формируем сообщение
|
||||
message, buttons = render_message(group_name, thread_id, alert)
|
||||
|
||||
# Обрезаем сообщение если оно слишком длинное
|
||||
message = truncate_message(message)
|
||||
|
||||
# Создаем Jira тикет, если включено и нужно
|
||||
jira_issue_key = None
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
if settings.jira_enabled:
|
||||
should_create_ticket = (
|
||||
(settings.jira_create_on_alert and alert.state == "alerting") or
|
||||
(settings.jira_create_on_resolved and alert.state == "ok")
|
||||
)
|
||||
|
||||
if should_create_ticket:
|
||||
try:
|
||||
jira_issue_key = await create_jira_ticket_from_alert(
|
||||
alert=alert,
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster
|
||||
)
|
||||
if jira_issue_key:
|
||||
# Добавляем кнопку Jira в сообщение
|
||||
jira_button = _create_jira_button(jira_issue_key, settings)
|
||||
if jira_button:
|
||||
if buttons:
|
||||
# Добавляем кнопку к существующим кнопкам
|
||||
new_buttons = buttons.inline_keyboard.copy()
|
||||
new_buttons.append([jira_button])
|
||||
buttons = InlineKeyboardMarkup(new_buttons)
|
||||
else:
|
||||
# Если кнопок еще нет, создаем новую клавиатуру
|
||||
buttons = InlineKeyboardMarkup([[jira_button]])
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Jira тикета: {e}")
|
||||
|
||||
# Преобразуем кнопки в универсальный формат
|
||||
buttons_dict = convert_telegram_buttons_to_dict(buttons)
|
||||
|
||||
# Отправляем сообщение
|
||||
success = await messenger_client.send_message(
|
||||
chat_id=chat_id,
|
||||
text=message,
|
||||
thread_id=final_thread_id,
|
||||
reply_markup=buttons_dict,
|
||||
disable_web_page_preview=True,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
if success:
|
||||
metrics.increment_sent_message(source, k8s_cluster, group_name, thread_id)
|
||||
|
||||
# Увеличиваем счетчики в зависимости от состояния
|
||||
if alert.state == "alerting":
|
||||
metrics.increment_firing_message(source, k8s_cluster, group_name, thread_id)
|
||||
elif alert.state == "ok":
|
||||
metrics.increment_resolved_message(source, k8s_cluster, group_name, thread_id)
|
||||
else:
|
||||
metrics.increment_error_message(source, k8s_cluster, group_name, thread_id)
|
||||
raise Exception(f"Ошибка отправки сообщения в {group_config.get('messenger', 'unknown')}")
|
||||
|
||||
|
||||
def _create_jira_button(issue_key: str, settings) -> Optional[InlineKeyboardButton]:
|
||||
"""
|
||||
Создать кнопку для ссылки на Jira тикет.
|
||||
|
||||
Args:
|
||||
issue_key: Ключ тикета Jira (например, "MON-123").
|
||||
settings: Настройки приложения.
|
||||
|
||||
Returns:
|
||||
InlineKeyboardButton с ссылкой на тикет или None.
|
||||
"""
|
||||
if not issue_key or not settings.jira_url:
|
||||
return None
|
||||
|
||||
jira_url = f"{settings.jira_url}/browse/{issue_key}"
|
||||
return InlineKeyboardButton(f"📋 Jira: {issue_key}", url=jira_url)
|
||||
|
||||
|
||||
def render_message(group_name: str, thread_id: int, alert: GrafanaAlert) -> Tuple[str, InlineKeyboardMarkup]:
|
||||
"""
|
||||
Сформировать сообщение и кнопки для мессенджера из алерта Grafana.
|
||||
|
||||
Args:
|
||||
group_name: Имя группы.
|
||||
thread_id: ID треда в группе.
|
||||
alert: Данные алерта из Grafana.
|
||||
|
||||
Returns:
|
||||
Кортеж (message, buttons).
|
||||
"""
|
||||
# Получаем иконки статуса
|
||||
alert_icon, status_icon, status_name = _get_status_icons(alert.state)
|
||||
|
||||
# Формируем словарь для шаблона
|
||||
message_dict = {
|
||||
'state': alert.state,
|
||||
'alert_icon': alert_icon,
|
||||
'status_icon': status_icon,
|
||||
'status_name': status_name,
|
||||
'title': alert.title,
|
||||
'message': alert.message or '',
|
||||
'ruleid': alert.ruleId,
|
||||
'rulename': alert.ruleName,
|
||||
'orgid': alert.orgId,
|
||||
'dashboardid': alert.dashboardId,
|
||||
'panelid': alert.panelId,
|
||||
}
|
||||
|
||||
# Обрабатываем evalMatches
|
||||
labels_text = ""
|
||||
for eval_match in alert.evalMatches:
|
||||
labels_text += f"<b>{eval_match.metric}:</b> {eval_match.value}\n"
|
||||
message_dict['labels'] = labels_text
|
||||
|
||||
# Обрабатываем теги
|
||||
tags_text = ""
|
||||
for key, value in alert.tags.items():
|
||||
tags_text += f"<b>{key}:</b> {value}\n"
|
||||
message_dict['tags'] = tags_text
|
||||
|
||||
# Получаем URL дашборда
|
||||
dashboard_url = alert.ruleUrl.split("?")[0] if alert.ruleUrl else ""
|
||||
rule_url = alert.ruleUrl
|
||||
|
||||
# Рендерим шаблон
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
environment = Environment(loader=FileSystemLoader(settings.templates_path))
|
||||
template = environment.get_template("grafana.tmpl")
|
||||
message = template.render(message_dict)
|
||||
|
||||
# Формируем кнопки
|
||||
buttons = render_buttons(dashboard_url, rule_url)
|
||||
|
||||
logger.info("Сообщение Grafana сформировано")
|
||||
return message, buttons
|
||||
|
||||
|
||||
def render_buttons(dashboard_url: str, rule_url: str) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Сформировать кнопки для сообщения мессенджера.
|
||||
|
||||
Args:
|
||||
dashboard_url: URL дашборда Grafana.
|
||||
rule_url: URL правила алерта.
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup с кнопками.
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
buttons = []
|
||||
|
||||
# Главная кнопка Grafana
|
||||
if settings.grafana_url:
|
||||
buttons.append([InlineKeyboardButton("Графана", url=settings.grafana_url)])
|
||||
|
||||
# Кнопки дашборда и алерта
|
||||
row_buttons = []
|
||||
if dashboard_url:
|
||||
row_buttons.append(InlineKeyboardButton("Дашборд", url=dashboard_url))
|
||||
if rule_url:
|
||||
row_buttons.append(InlineKeyboardButton("Алерт", url=rule_url))
|
||||
|
||||
if row_buttons:
|
||||
buttons.append(row_buttons)
|
||||
|
||||
markup = InlineKeyboardMarkup(buttons) if buttons else None
|
||||
logger.debug("Кнопки Grafana сгенерированы")
|
||||
return markup
|
||||
252
app/modules/zabbix.py
Normal file
252
app/modules/zabbix.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Модуль для обработки алертов из Zabbix.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from typing import Tuple, Optional
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
from app.models.zabbix import ZabbixAlert
|
||||
from app.core.groups import groups_config
|
||||
from app.core.metrics import metrics
|
||||
from app.core.jira_utils import create_jira_ticket_from_alert
|
||||
from app.core.utils import truncate_message
|
||||
from app.core.messenger_factory import MessengerFactory
|
||||
from app.core.button_utils import convert_telegram_buttons_to_dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_status_icons(severity: Optional[str], status: str) -> Tuple[str, str, str]:
|
||||
"""
|
||||
Получить иконки и название статуса в зависимости от серьезности и статуса алерта.
|
||||
|
||||
Args:
|
||||
severity: Уровень серьезности (Disaster, High, Warning, Average, Information).
|
||||
status: Статус события (OK, PROBLEM).
|
||||
|
||||
Returns:
|
||||
Кортеж (alert_icon, status_icon, status_name).
|
||||
"""
|
||||
severity = severity or "Information"
|
||||
|
||||
status_map = {
|
||||
("Disaster", "PROBLEM"): ("🔴", "💀", "Катастрофа. Бросаем все и чиним."),
|
||||
("Disaster", "OK"): ("🟢", "💀", "Катастрофы избежали. Все работает. Пошли смотреть логи!"),
|
||||
("High", "PROBLEM"): ("🟠", "😡", "Оперативно реагируем, диагностируем и чиним."),
|
||||
("High", "OK"): ("🟢", "😡", "Отреагировали. Продиагностировали и починили."),
|
||||
("Warning", "PROBLEM"): ("🟡", "🤔", "Ой. Что-то сломалось! Пойдем посмотрим?!"),
|
||||
("Warning", "OK"): ("🟢", "🤔", "Посмотрели. Доламывать не стали. Починили."),
|
||||
("Average", "PROBLEM"): ("🔵", "😒", "Ну такое себе. Но можно глянуть..."),
|
||||
("Average", "OK"): ("🟢", "😒", "Пока ничего критичного. Само починилось"),
|
||||
("Information", "PROBLEM"): ("🟣", "👻", "Ничего критичного. Просто информирую"),
|
||||
("Information", "OK"): ("🟢", "👻", "Все молодцы. IT + DevOps = 🤝"),
|
||||
}
|
||||
|
||||
return status_map.get((severity, status), ("🔸", "ℹ️", severity))
|
||||
|
||||
|
||||
async def send(group_name: str, thread_id: int, alert: ZabbixAlert, messenger: Optional[str] = None) -> None:
|
||||
"""
|
||||
Отправить алерт из Zabbix в мессенджер.
|
||||
|
||||
Args:
|
||||
group_name: Имя группы из конфигурации.
|
||||
thread_id: ID треда в группе (0 для основной группы).
|
||||
alert: Данные алерта из Zabbix.
|
||||
messenger: Тип мессенджера (опционально, если не указан - используется из конфигурации группы).
|
||||
|
||||
Raises:
|
||||
ValueError: Если группа не найдена в конфигурации.
|
||||
"""
|
||||
source = "zabbix"
|
||||
k8s_cluster = ""
|
||||
|
||||
# Увеличиваем счетчик полученных сообщений
|
||||
metrics.increment_total_message(source, k8s_cluster, group_name, thread_id)
|
||||
|
||||
# Получаем конфигурацию группы
|
||||
group_config = await groups_config.get_group_config(group_name, messenger)
|
||||
if group_config is None:
|
||||
raise ValueError(f"Группа '{group_name}' не найдена в конфигурации")
|
||||
|
||||
messenger_type = group_config.get("messenger", "telegram")
|
||||
chat_id = group_config.get("chat_id")
|
||||
group_thread_id = group_config.get("thread_id", 0)
|
||||
|
||||
# Используем thread_id из параметра, если указан, иначе из конфигурации группы
|
||||
final_thread_id = thread_id if thread_id > 0 else group_thread_id
|
||||
|
||||
# Создаем клиент мессенджера
|
||||
messenger_client = MessengerFactory.create_from_config(group_config)
|
||||
|
||||
# Проверяем поддержку тредов
|
||||
if not messenger_client.supports_threads and final_thread_id > 0:
|
||||
logger.warning(f"Мессенджер '{messenger_type}' не поддерживает треды, thread_id будет проигнорирован")
|
||||
final_thread_id = None
|
||||
elif final_thread_id == 0:
|
||||
final_thread_id = None
|
||||
|
||||
# Формируем сообщение
|
||||
message, buttons = render_message(group_name, thread_id, alert)
|
||||
|
||||
# Обрезаем сообщение если оно слишком длинное
|
||||
message = truncate_message(message)
|
||||
|
||||
# Создаем Jira тикет, если включено и нужно
|
||||
jira_issue_key = None
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
if settings.jira_enabled:
|
||||
should_create_ticket = (
|
||||
(settings.jira_create_on_alert and alert.status == "PROBLEM") or
|
||||
(settings.jira_create_on_resolved and alert.status == "OK")
|
||||
)
|
||||
|
||||
if should_create_ticket:
|
||||
try:
|
||||
jira_issue_key = await create_jira_ticket_from_alert(
|
||||
alert=alert,
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster
|
||||
)
|
||||
if jira_issue_key:
|
||||
# Добавляем кнопку Jira в сообщение
|
||||
jira_button = _create_jira_button(jira_issue_key, settings)
|
||||
if jira_button:
|
||||
if buttons:
|
||||
# Добавляем кнопку к существующим кнопкам
|
||||
new_buttons = buttons.inline_keyboard.copy()
|
||||
new_buttons.append([jira_button])
|
||||
buttons = InlineKeyboardMarkup(new_buttons)
|
||||
else:
|
||||
# Если кнопок еще нет, создаем новую клавиатуру
|
||||
buttons = InlineKeyboardMarkup([[jira_button]])
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Jira тикета: {e}")
|
||||
|
||||
# Преобразуем кнопки в универсальный формат
|
||||
buttons_dict = convert_telegram_buttons_to_dict(buttons)
|
||||
|
||||
# Отправляем сообщение
|
||||
success = await messenger_client.send_message(
|
||||
chat_id=chat_id,
|
||||
text=message,
|
||||
thread_id=final_thread_id,
|
||||
reply_markup=buttons_dict,
|
||||
disable_web_page_preview=True,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
if success:
|
||||
metrics.increment_sent_message(source, k8s_cluster, group_name, thread_id)
|
||||
|
||||
# Увеличиваем счетчики в зависимости от статуса
|
||||
if alert.status == "PROBLEM":
|
||||
metrics.increment_firing_message(source, k8s_cluster, group_name, thread_id)
|
||||
elif alert.status == "OK":
|
||||
metrics.increment_resolved_message(source, k8s_cluster, group_name, thread_id)
|
||||
else:
|
||||
metrics.increment_error_message(source, k8s_cluster, group_name, thread_id)
|
||||
raise Exception(f"Ошибка отправки сообщения в {group_config.get('messenger', 'unknown')}")
|
||||
|
||||
|
||||
def _create_jira_button(issue_key: str, settings) -> Optional[InlineKeyboardButton]:
|
||||
"""
|
||||
Создать кнопку для ссылки на Jira тикет.
|
||||
|
||||
Args:
|
||||
issue_key: Ключ тикета Jira (например, "MON-123").
|
||||
settings: Настройки приложения.
|
||||
|
||||
Returns:
|
||||
InlineKeyboardButton с ссылкой на тикет или None.
|
||||
"""
|
||||
if not issue_key or not settings.jira_url:
|
||||
return None
|
||||
|
||||
jira_url = f"{settings.jira_url}/browse/{issue_key}"
|
||||
return InlineKeyboardButton(f"📋 Jira: {issue_key}", url=jira_url)
|
||||
|
||||
|
||||
def render_message(group_name: str, thread_id: int, alert: ZabbixAlert) -> Tuple[str, InlineKeyboardMarkup]:
|
||||
"""
|
||||
Сформировать сообщение и кнопки для мессенджера из алерта Zabbix.
|
||||
|
||||
Args:
|
||||
group_name: Имя группы.
|
||||
thread_id: ID треда в группе.
|
||||
alert: Данные алерта из Zabbix.
|
||||
|
||||
Returns:
|
||||
Кортеж (message, buttons).
|
||||
"""
|
||||
# Получаем иконки статуса
|
||||
severity = alert.event_severity or "Information"
|
||||
alert_icon, status_icon, status_name = _get_status_icons(severity, alert.status)
|
||||
|
||||
# Формируем словарь для шаблона
|
||||
message_dict = {
|
||||
'state': severity,
|
||||
'alert_icon': alert_icon,
|
||||
'status_icon': status_icon,
|
||||
'status_name': status_name,
|
||||
'title': alert.event_name,
|
||||
'subject': alert.alert_subject,
|
||||
'message': alert.alert_message,
|
||||
'message_data': alert.event_opdata or '',
|
||||
'label_date': alert.event_recovery_date or '',
|
||||
'label_time': alert.event_recovery_time or '',
|
||||
'label_duration': alert.event_duration or '',
|
||||
'label_host': alert.host_name,
|
||||
'label_ip': alert.host_ip,
|
||||
'label_port': alert.host_port,
|
||||
}
|
||||
|
||||
# Рендерим шаблон
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
environment = Environment(loader=FileSystemLoader(settings.templates_path))
|
||||
template = environment.get_template("zabbix.tmpl")
|
||||
message = template.render(message_dict)
|
||||
|
||||
# Формируем кнопки
|
||||
alert_url_path = alert.link.split("/")[-1] if alert.link else ""
|
||||
buttons = render_buttons(alert_url_path)
|
||||
|
||||
logger.info("Сообщение Zabbix сформировано")
|
||||
return message, buttons
|
||||
|
||||
|
||||
def render_buttons(alert_url_path: str) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Сформировать кнопки для сообщения мессенджера.
|
||||
|
||||
Args:
|
||||
alert_url_path: Путь к событию в Zabbix.
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup с кнопками.
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
buttons = []
|
||||
|
||||
if settings.zabbix_url:
|
||||
# Кнопка Zabbix
|
||||
buttons.append([InlineKeyboardButton("Заббикс", url=settings.zabbix_url)])
|
||||
|
||||
# Кнопка перехода к алерту
|
||||
if alert_url_path:
|
||||
alert_url = f"{settings.zabbix_url}/{alert_url_path}"
|
||||
buttons.append([InlineKeyboardButton("Перейти к алерту", url=alert_url)])
|
||||
|
||||
markup = InlineKeyboardMarkup(buttons) if buttons else None
|
||||
logger.debug("Кнопки Zabbix сгенерированы")
|
||||
return markup
|
||||
24
config/groups.json.example
Normal file
24
config/groups.json.example
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"monitoring": {
|
||||
"messenger": "telegram",
|
||||
"chat_id": -123456789,
|
||||
"thread_id": 0,
|
||||
"config": {}
|
||||
},
|
||||
"alerts_max": {
|
||||
"messenger": "max",
|
||||
"chat_id": "123456789",
|
||||
"thread_id": 0,
|
||||
"config": {
|
||||
"access_token": "your_access_token_here",
|
||||
"api_version": "5.131"
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
"messenger": "telegram",
|
||||
"chat_id": -12345678,
|
||||
"thread_id": 0,
|
||||
"config": {}
|
||||
}
|
||||
}
|
||||
|
||||
29
docker-compose.yaml
Normal file
29
docker-compose.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
message-gateway:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: message-gateway
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./app:/app/app
|
||||
- ./config:/app/config
|
||||
- ./templates:/app/templates
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- message-gateway-network
|
||||
|
||||
networks:
|
||||
message-gateway-network:
|
||||
driver: bridge
|
||||
32
docs/README.md
Normal file
32
docs/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Документация Message Gateway
|
||||
|
||||
Добро пожаловать в документацию проекта Message Gateway!
|
||||
|
||||
## Содержание
|
||||
|
||||
### Основная документация
|
||||
|
||||
- [Настройка ботов](bots.md) - как создать ботов в Telegram и MAX/VK
|
||||
- [Конфигурация групп](groups.md) - как настроить группы мессенджеров
|
||||
- [Шаблоны сообщений](templates.md) - как работают шаблоны Jinja2
|
||||
- [Конфигурация Jira Mapping](jira-mapping.md) - как настроить маппинг алертов в Jira
|
||||
- [Отправка сообщений](messaging.md) - как работает отправка сообщений
|
||||
|
||||
### Настройка систем мониторинга
|
||||
|
||||
- [Настройка Grafana](monitoring/grafana.md) - как настроить Grafana для отправки алертов
|
||||
- [Настройка Zabbix](monitoring/zabbix.md) - как настроить Zabbix для отправки алертов
|
||||
- [Настройка AlertManager](monitoring/alertmanager.md) - как настроить AlertManager для отправки алертов
|
||||
|
||||
### API документация
|
||||
|
||||
- [API для управления группами](api/groups.md) - управление группами мессенджеров через API
|
||||
- [API для отправки сообщений](api/message.md) - отправка простых сообщений в мессенджеры
|
||||
- [API для проверки здоровья](api/health.md) - проверка здоровья и готовности приложения
|
||||
- [API для отладки](api/debug.md) - сохранение JSON данных для отладки
|
||||
- [Декораторы API](api/decorators.md) - декораторы для авторизации и скрытия эндпоинтов
|
||||
|
||||
---
|
||||
|
||||
**Автор:** Сергей Антропов
|
||||
**Сайт:** https://devops.org.ru
|
||||
105
docs/api/debug.md
Normal file
105
docs/api/debug.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# API для отладки
|
||||
|
||||
**Важно:** Эндпоинт `/api/v1/debug/dump` скрыт из Swagger UI для безопасности, но остается доступным для прямых запросов. Используйте его только для отладки.
|
||||
|
||||
## Сохранение JSON данных для отладки
|
||||
|
||||
```bash
|
||||
POST /api/v1/debug/dump
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"test": "data",
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"source": "grafana",
|
||||
"alert": {
|
||||
"title": "Test alert",
|
||||
"state": "alerting"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Пример ответа:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "Данные сохранены в dump.json",
|
||||
"data": {
|
||||
"test": "data",
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"source": "grafana",
|
||||
"alert": {
|
||||
"title": "Test alert",
|
||||
"state": "alerting"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Примечания
|
||||
|
||||
- Используется для отладки входящих webhook запросов
|
||||
- Данные сохраняются в файл `/app/app/dump.json` в контейнере
|
||||
- Файл можно просмотреть через `make shell` или `make docker shell`
|
||||
- Полезно для анализа формата данных от различных источников
|
||||
- **Эндпоинт скрыт из Swagger UI** с помощью декоратора `@hide_from_api` для безопасности
|
||||
- Эндпоинт не требует авторизации (API ключ не нужен)
|
||||
- Используется только для внутренней отладки
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### Сохранение данных из Grafana
|
||||
|
||||
```bash
|
||||
curl -X POST "http://your-gateway-url/api/v1/debug/dump" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"source": "grafana",
|
||||
"alert": {
|
||||
"title": "Test alert",
|
||||
"state": "alerting"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Сохранение данных из Zabbix
|
||||
|
||||
```bash
|
||||
curl -X POST "http://your-gateway-url/api/v1/debug/dump" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"source": "zabbix",
|
||||
"event": {
|
||||
"event-id": "8819711",
|
||||
"event-name": "High CPU utilization",
|
||||
"status": "PROBLEM"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Сохранение данных из AlertManager
|
||||
|
||||
```bash
|
||||
curl -X POST "http://your-gateway-url/api/v1/debug/dump" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"source": "alertmanager",
|
||||
"status": "firing",
|
||||
"commonLabels": {
|
||||
"alertname": "HighCPUUsage",
|
||||
"severity": "critical"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## Просмотр сохраненных данных
|
||||
|
||||
```bash
|
||||
# Через make shell
|
||||
make shell
|
||||
cat /app/app/dump.json
|
||||
|
||||
# Или напрямую через docker
|
||||
docker exec -it message-gateway cat /app/app/dump.json
|
||||
```
|
||||
|
||||
128
docs/api/decorators.md
Normal file
128
docs/api/decorators.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Декораторы API
|
||||
|
||||
В проекте Message Gateway используются декораторы для упрощения работы с API и обеспечения безопасности.
|
||||
|
||||
## Декораторы
|
||||
|
||||
### `@require_api_key`
|
||||
|
||||
Декоратор для пометки эндпоинта как требующего API ключ для авторизации.
|
||||
|
||||
**Использование:**
|
||||
```python
|
||||
from app.core.auth import require_api_key, require_api_key_dependency
|
||||
|
||||
@require_api_key
|
||||
@router.post("/endpoint", dependencies=[require_api_key_dependency])
|
||||
async def my_endpoint(request: Request, ...):
|
||||
...
|
||||
```
|
||||
|
||||
**Примечания:**
|
||||
- Декоратор используется только для пометки функции
|
||||
- Фактическая проверка API ключа выполняется через `dependencies=[require_api_key_dependency]`
|
||||
- Это обеспечивает отображение замочка в Swagger UI
|
||||
- API ключ настраивается в переменной окружения `API_KEY` в файле `.env`
|
||||
- API ключ передается в заголовке `X-API-Key`
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
from app.core.auth import require_api_key, require_api_key_dependency
|
||||
from fastapi import APIRouter, Request, Body
|
||||
|
||||
router = APIRouter(prefix="/api/v1/groups", tags=["groups"])
|
||||
|
||||
@require_api_key
|
||||
@router.post(
|
||||
"",
|
||||
name="Создать группу",
|
||||
dependencies=[require_api_key_dependency]
|
||||
)
|
||||
async def create_group(
|
||||
request: Request,
|
||||
body: CreateGroupRequest = Body(...)
|
||||
) -> Dict[str, Any]:
|
||||
...
|
||||
```
|
||||
|
||||
### `@hide_from_api`
|
||||
|
||||
Декоратор для скрытия эндпоинта из API документации (Swagger UI).
|
||||
|
||||
**Использование:**
|
||||
```python
|
||||
from app.core.auth import hide_from_api
|
||||
|
||||
@hide_from_api
|
||||
@router.post("/debug/dump", include_in_schema=False)
|
||||
async def debug_endpoint(...):
|
||||
...
|
||||
```
|
||||
|
||||
**Примечания:**
|
||||
- Декоратор помечает функцию как скрытую от API
|
||||
- Эндпоинт все еще будет работать, но не будет отображаться в Swagger UI
|
||||
- Рекомендуется использовать вместе с параметром `include_in_schema=False` в декораторе route для надежного скрытия
|
||||
- Используется для скрытия внутренних эндпоинтов от публичной документации
|
||||
- Полезно для отладочных эндпоинтов и административных функций
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
from app.core.auth import hide_from_api
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
router = APIRouter(prefix="/api/v1/debug", tags=["debug"])
|
||||
|
||||
@hide_from_api
|
||||
@router.post(
|
||||
"/dump",
|
||||
name="JSON Debug dump",
|
||||
include_in_schema=False, # Скрываем эндпоинт из Swagger UI
|
||||
response_model=Dict[str, Any]
|
||||
)
|
||||
async def dump_request(
|
||||
dump: Dict[str, Any] = Body(...)
|
||||
) -> Dict[str, Any]:
|
||||
...
|
||||
```
|
||||
|
||||
## Комбинирование декораторов
|
||||
|
||||
Декораторы можно комбинировать для более сложных сценариев:
|
||||
|
||||
```python
|
||||
from app.core.auth import require_api_key, require_api_key_dependency, hide_from_api
|
||||
|
||||
@require_api_key
|
||||
@hide_from_api
|
||||
@router.post(
|
||||
"/internal/admin",
|
||||
dependencies=[require_api_key_dependency],
|
||||
include_in_schema=False
|
||||
)
|
||||
async def internal_admin_endpoint(request: Request, ...):
|
||||
...
|
||||
```
|
||||
|
||||
В этом примере:
|
||||
- Эндпоинт требует API ключ для доступа
|
||||
- Эндпоинт скрыт из Swagger UI
|
||||
- Эндпоинт доступен только для администраторов с правильным API ключом
|
||||
|
||||
## Примечания
|
||||
|
||||
- Декораторы применяются снизу вверх (от функции к верхнему декоратору)
|
||||
- Порядок декораторов важен: сначала применяется декоратор route (`@router.post`), затем декораторы авторизации и скрытия
|
||||
- Декораторы не изменяют функциональность эндпоинта, они только добавляют метаданные или проверки
|
||||
- Все декораторы определены в модуле `app.core.auth`
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
- Подробная документация по API ключам: см. [API для управления группами](groups.md)
|
||||
- Подробная документация по отладочным эндпоинтам: см. [API для отладки](debug.md)
|
||||
|
||||
---
|
||||
|
||||
**Автор:** Сергей Антропов
|
||||
**Сайт:** https://devops.org.ru
|
||||
|
||||
164
docs/api/groups.md
Normal file
164
docs/api/groups.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# API для управления группами
|
||||
|
||||
## Получение списка поддерживаемых мессенджеров
|
||||
|
||||
```bash
|
||||
GET /api/v1/groups/messengers
|
||||
```
|
||||
|
||||
**Пример ответа:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"messengers": [
|
||||
{
|
||||
"type": "telegram",
|
||||
"name": "Telegram",
|
||||
"supports_threads": true,
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"type": "max",
|
||||
"name": "MAX/VK",
|
||||
"supports_threads": false,
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Получение списка групп
|
||||
|
||||
```bash
|
||||
# Без API ключа (только названия)
|
||||
GET /api/v1/groups
|
||||
|
||||
# С API ключом (полная информация)
|
||||
GET /api/v1/groups
|
||||
Headers: X-API-Key: your_api_key_here
|
||||
```
|
||||
|
||||
**Пример ответа (без API ключа):**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"groups": [
|
||||
{"name": "monitoring", "chat_id": null},
|
||||
{"name": "alerts", "chat_id": null}
|
||||
],
|
||||
"count": 2
|
||||
}
|
||||
```
|
||||
|
||||
**Пример ответа (с API ключом):**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"groups": [
|
||||
{
|
||||
"name": "monitoring",
|
||||
"messenger": "telegram",
|
||||
"chat_id": -1001234567890,
|
||||
"thread_id": 0
|
||||
},
|
||||
{
|
||||
"name": "alerts_max",
|
||||
"messenger": "max",
|
||||
"chat_id": "123456789",
|
||||
"thread_id": null
|
||||
}
|
||||
],
|
||||
"count": 2
|
||||
}
|
||||
```
|
||||
|
||||
## Создание группы
|
||||
|
||||
```bash
|
||||
POST /api/v1/groups
|
||||
Headers:
|
||||
Content-Type: application/json
|
||||
X-API-Key: your_api_key_here
|
||||
|
||||
{
|
||||
"group_name": "monitoring",
|
||||
"messenger": "telegram",
|
||||
"chat_id": -1001234567890,
|
||||
"thread_id": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Пример запроса для MAX/VK:**
|
||||
```bash
|
||||
POST /api/v1/groups
|
||||
Headers:
|
||||
Content-Type: application/json
|
||||
X-API-Key: your_api_key_here
|
||||
|
||||
{
|
||||
"group_name": "max_alerts",
|
||||
"messenger": "max",
|
||||
"chat_id": "123456789",
|
||||
"thread_id": 0,
|
||||
"config": {
|
||||
"access_token": "your_max_access_token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Пример ответа:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "Группа 'monitoring' создана с мессенджером 'telegram' и ID -1001234567890"
|
||||
}
|
||||
```
|
||||
|
||||
## Обновление группы
|
||||
|
||||
```bash
|
||||
PUT /api/v1/groups/{group_name}
|
||||
Headers:
|
||||
Content-Type: application/json
|
||||
X-API-Key: your_api_key_here
|
||||
|
||||
{
|
||||
"messenger": "telegram",
|
||||
"chat_id": -1001234567891,
|
||||
"thread_id": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Пример ответа:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "Группа 'monitoring' обновлена"
|
||||
}
|
||||
```
|
||||
|
||||
## Удаление группы
|
||||
|
||||
```bash
|
||||
DELETE /api/v1/groups/{group_name}
|
||||
Headers: X-API-Key: your_api_key_here
|
||||
```
|
||||
|
||||
**Пример ответа:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "Группа 'monitoring' удалена"
|
||||
}
|
||||
```
|
||||
|
||||
## Примечания
|
||||
|
||||
- Требуется API ключ в заголовке X-API-Key для всех операций, кроме получения списка групп (без API ключа возвращаются только названия)
|
||||
- Группы сохраняются в `config/groups.json`
|
||||
- Chat ID можно получить, добавив бота в группу и отправив сообщение
|
||||
- Используйте `@userinfobot` в Telegram для получения Chat ID группы
|
||||
- Для MAX/VK используйте peer_id (может быть строкой или числом)
|
||||
- Thread ID поддерживается только для Telegram (для MAX/VK игнорируется)
|
||||
- Дополнительная конфигурация (config) может содержать параметры для конкретного мессенджера (например, access_token для MAX/VK)
|
||||
|
||||
63
docs/api/health.md
Normal file
63
docs/api/health.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# API для проверки здоровья
|
||||
|
||||
## Проверка здоровья и готовности приложения
|
||||
|
||||
```bash
|
||||
GET /api/v1/health
|
||||
```
|
||||
|
||||
**Пример ответа (здорово):**
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"state": "online",
|
||||
"telegram_bot_configured": true,
|
||||
"groups_config_available": true
|
||||
}
|
||||
```
|
||||
|
||||
**Пример ответа (не готово):**
|
||||
```json
|
||||
{
|
||||
"status": "not_ready",
|
||||
"state": "online",
|
||||
"checks": {
|
||||
"telegram_bot_configured": false,
|
||||
"groups_config_available": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Примечания
|
||||
|
||||
- Не требует аутентификации
|
||||
- Возвращает 503 если приложение не готово
|
||||
- Используется Kubernetes для liveness и readiness probes
|
||||
- Настройте liveness и readiness probes в kubernetes.yaml
|
||||
- Приложение считается готовым, если все проверки пройдены
|
||||
|
||||
## Проверки
|
||||
|
||||
- `telegram_bot_configured` - наличие токена Telegram (TELEGRAM_BOT_TOKEN)
|
||||
- `groups_config_available` - доступность файла конфигурации групп (config/groups.json)
|
||||
|
||||
## Использование в Kubernetes
|
||||
|
||||
Настройте liveness и readiness probes в `kubernetes.yaml`:
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health
|
||||
port: 8000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health
|
||||
port: 8000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
```
|
||||
|
||||
188
docs/api/message.md
Normal file
188
docs/api/message.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# API для отправки сообщений
|
||||
|
||||
**Важно:** Все эндпоинты для отправки сообщений требуют API ключ в заголовке `X-API-Key`.
|
||||
|
||||
## Аутентификация
|
||||
|
||||
Все эндпоинты требуют API ключ в заголовке запроса:
|
||||
|
||||
```bash
|
||||
X-API-Key: your_api_key_here
|
||||
```
|
||||
|
||||
API ключ настраивается в переменной окружения `API_KEY` в файле `.env`.
|
||||
|
||||
## Отправка текстового сообщения
|
||||
|
||||
```bash
|
||||
POST /api/v1/message/text
|
||||
Content-Type: application/json
|
||||
X-API-Key: your_api_key_here
|
||||
|
||||
{
|
||||
"tg_group": "monitoring",
|
||||
"tg_thread_id": 0,
|
||||
"text": "<b>Критическое уведомление</b>\n\nСистема недоступна!\n\n<i>Время: 2024-02-08 16:49:44</i>",
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": false
|
||||
}
|
||||
```
|
||||
|
||||
**Пример ответа:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "Сообщение отправлено в чат monitoring, тред 0"
|
||||
}
|
||||
```
|
||||
|
||||
**Поддерживаемые режимы парсинга:**
|
||||
- `HTML` - HTML форматирование (рекомендуется)
|
||||
- `Markdown` - Markdown форматирование
|
||||
- `MarkdownV2` - Markdown V2 форматирование
|
||||
|
||||
**Примеры форматирования:**
|
||||
- HTML: `<b>жирный</b>`, `<i>курсив</i>`, `<code>код</code>`
|
||||
- Markdown: `**жирный**`, `*курсив*`, `` `код` ``
|
||||
|
||||
## Отправка фото
|
||||
|
||||
```bash
|
||||
POST /api/v1/message/photo
|
||||
Content-Type: application/json
|
||||
X-API-Key: your_api_key_here
|
||||
|
||||
{
|
||||
"tg_group": "monitoring",
|
||||
"tg_thread_id": 0,
|
||||
"photo": "https://grafana.example.com/render/dashboard-solo?panelId=1&width=1000&height=500",
|
||||
"caption": "<b>График CPU</b>\n\n<i>Время: 2024-02-08 16:49:44</i>",
|
||||
"parse_mode": "HTML"
|
||||
}
|
||||
```
|
||||
|
||||
**Пример ответа:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "Фото отправлено в чат monitoring, тред 0"
|
||||
}
|
||||
```
|
||||
|
||||
**Примечания:**
|
||||
- Фото может быть передано через URL (автоматически загрузится) или как файл
|
||||
- Поддерживаются форматы: JPG, PNG, GIF, WebP
|
||||
- Максимальный размер файла: 10 МБ (Telegram), зависит от мессенджера
|
||||
- Для отправки графиков из Grafana используйте URL рендеринга дашбордов
|
||||
|
||||
## Отправка видео
|
||||
|
||||
```bash
|
||||
POST /api/v1/message/video
|
||||
Content-Type: application/json
|
||||
X-API-Key: your_api_key_here
|
||||
|
||||
{
|
||||
"tg_group": "monitoring",
|
||||
"tg_thread_id": 0,
|
||||
"video": "https://example.com/recording.webm",
|
||||
"caption": "<b>Запись работы системы</b>\n\n<i>Длительность: 60 сек</i>",
|
||||
"parse_mode": "HTML",
|
||||
"duration": 60,
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
}
|
||||
```
|
||||
|
||||
**Пример ответа:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "Видео отправлено в чат monitoring, тред 0"
|
||||
}
|
||||
```
|
||||
|
||||
**Примечания:**
|
||||
- Видео может быть передано через URL (автоматически загрузится) или как файл
|
||||
- Поддерживаются форматы: MP4, WebM, MOV (зависит от мессенджера)
|
||||
- Максимальный размер файла: 50 МБ (Telegram), зависит от мессенджера
|
||||
- Длительность, ширина и высота опциональны, но рекомендуются для лучшего отображения
|
||||
|
||||
## Отправка аудио
|
||||
|
||||
```bash
|
||||
POST /api/v1/message/audio
|
||||
Content-Type: application/json
|
||||
X-API-Key: your_api_key_here
|
||||
|
||||
{
|
||||
"tg_group": "monitoring",
|
||||
"tg_thread_id": 0,
|
||||
"audio": "https://example.com/notification.ogg",
|
||||
"caption": "<b>Аудио уведомление</b>\n\n<i>Система работает нормально</i>",
|
||||
"parse_mode": "HTML",
|
||||
"duration": 30,
|
||||
"performer": "System Notification",
|
||||
"title": "Alert Notification"
|
||||
}
|
||||
```
|
||||
|
||||
**Пример ответа:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "Аудио отправлено в чат monitoring, тред 0"
|
||||
}
|
||||
```
|
||||
|
||||
**Примечания:**
|
||||
- Аудио может быть передано через URL (автоматически загрузится) или как файл
|
||||
- Поддерживаются форматы: MP3, OGG, WAV, M4A (зависит от мессенджера)
|
||||
- Максимальный размер файла: 50 МБ (Telegram), зависит от мессенджера
|
||||
- Исполнитель и название опциональны, но улучшают отображение в Telegram
|
||||
|
||||
## Отправка документа
|
||||
|
||||
```bash
|
||||
POST /api/v1/message/document
|
||||
Content-Type: application/json
|
||||
X-API-Key: your_api_key_here
|
||||
|
||||
{
|
||||
"tg_group": "monitoring",
|
||||
"tg_thread_id": 0,
|
||||
"document": "https://example.com/report.xlsx",
|
||||
"caption": "<b>Отчет за неделю</b>\n\n<i>Дата: 2024-02-08</i>",
|
||||
"parse_mode": "HTML",
|
||||
"filename": "report_2024-02-08.xlsx"
|
||||
}
|
||||
```
|
||||
|
||||
**Пример ответа:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "Документ отправлен в чат monitoring, тред 0"
|
||||
}
|
||||
```
|
||||
|
||||
**Примечания:**
|
||||
- Документ может быть передан через URL (автоматически загрузится) или как файл
|
||||
- Поддерживаются все форматы файлов (PDF, DOCX, XLSX, TXT, и т.д.)
|
||||
- Максимальный размер файла: 50 МБ (Telegram), зависит от мессенджера
|
||||
- Имя файла опционально, если не указано - используется имя из URL
|
||||
- Для отправки отчетов используйте прямые ссылки на файлы
|
||||
|
||||
## Параметры запроса
|
||||
|
||||
Все endpoints поддерживают параметр `messenger` (опционально):
|
||||
- `?messenger=telegram` - использовать Telegram
|
||||
- `?messenger=max` - использовать MAX/VK
|
||||
- Если не указан, используется мессенджер из конфигурации группы
|
||||
|
||||
## Общие параметры
|
||||
|
||||
- `tg_group` - имя группы из конфигурации (config/groups.json)
|
||||
- `tg_thread_id` - ID треда в группе (0 для основной группы, поддерживается только для Telegram)
|
||||
- `messenger` - тип мессенджера (опционально, `telegram`, `max`, по умолчанию используется из конфигурации группы)
|
||||
|
||||
91
docs/bots.md
Normal file
91
docs/bots.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Настройка ботов
|
||||
|
||||
## Создание бота в Telegram
|
||||
|
||||
1. **Создание бота через BotFather:**
|
||||
- Откройте Telegram и найдите бота [@BotFather](https://t.me/BotFather)
|
||||
- Отправьте команду `/newbot`
|
||||
- Следуйте инструкциям BotFather:
|
||||
- Введите имя бота (например, "My Monitoring Bot")
|
||||
- Введите username бота (должен заканчиваться на `bot`, например, "my_monitoring_bot")
|
||||
- BotFather предоставит вам **токен бота** (например, `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`)
|
||||
|
||||
2. **Настройка бота:**
|
||||
- Сохраните токен бота в переменную окружения `TELEGRAM_BOT_TOKEN`
|
||||
- Добавьте бота в группу Telegram, куда нужно отправлять сообщения
|
||||
- Сделайте бота администратором группы (опционально, но рекомендуется для полной функциональности)
|
||||
|
||||
3. **Получение Chat ID группы:**
|
||||
- Добавьте бота [@userinfobot](https://t.me/userinfobot) в группу
|
||||
- Отправьте любое сообщение в группе
|
||||
- @userinfobot покажет Chat ID группы (например, `-1001234567890`)
|
||||
- Сохраните Chat ID в конфигурацию групп (`config/groups.json`)
|
||||
|
||||
4. **Получение Thread ID (для тредов):**
|
||||
- Создайте тред в группе Telegram
|
||||
- Используйте бота [@userinfobot](https://t.me/userinfobot) или API Telegram для получения Thread ID
|
||||
- Thread ID можно получить через API Telegram: `getUpdates` или `getChat`
|
||||
|
||||
**Пример конфигурации для Telegram:**
|
||||
```json
|
||||
{
|
||||
"monitoring": {
|
||||
"messenger": "telegram",
|
||||
"chat_id": -1001234567890,
|
||||
"thread_id": 0,
|
||||
"config": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Создание бота в MAX/VK
|
||||
|
||||
1. **Создание приложения в VK:**
|
||||
- Перейдите на [VK Developers](https://dev.vk.com/)
|
||||
- Создайте новое приложение:
|
||||
- Нажмите "Создать приложение"
|
||||
- Выберите тип приложения: "Веб-сайт" или "Standalone"
|
||||
- Введите название приложения
|
||||
- Подтвердите создание
|
||||
|
||||
2. **Получение Access Token:**
|
||||
- В настройках приложения найдите раздел "Токены"
|
||||
- Создайте новый токен:
|
||||
- Выберите права доступа (scope):
|
||||
- `messages` - для отправки сообщений
|
||||
- `photos` - для отправки фотографий
|
||||
- `docs` - для отправки документов
|
||||
- Скопируйте полученный токен
|
||||
- Сохраните токен в переменную окружения `MAX_ACCESS_TOKEN`
|
||||
|
||||
3. **Получение Peer ID (ID чата):**
|
||||
- Peer ID можно получить через API VK: `messages.getConversations`
|
||||
- Для группы: Peer ID = `2000000000 + group_id`
|
||||
- Для беседы: Peer ID можно получить через `messages.getChat`
|
||||
|
||||
4. **Настройка приложения:**
|
||||
- Установите переменные окружения:
|
||||
- `MAX_ACCESS_TOKEN` - токен доступа
|
||||
- `MAX_API_VERSION` - версия API (по умолчанию `5.131`)
|
||||
- `MAX_ENABLED=true` - включить поддержку MAX/VK
|
||||
|
||||
**Пример конфигурации для MAX/VK:**
|
||||
```json
|
||||
{
|
||||
"max_alerts": {
|
||||
"messenger": "max",
|
||||
"chat_id": "123456789",
|
||||
"thread_id": null,
|
||||
"config": {
|
||||
"access_token": "your_access_token_here",
|
||||
"api_version": "5.131"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Примечания:**
|
||||
- MAX/VK не поддерживает треды (thread_id всегда `null`)
|
||||
- Peer ID может быть как числом, так и строкой
|
||||
- Для работы с группами VK требуется, чтобы бот был добавлен в группу и имел права администратора
|
||||
|
||||
195
docs/groups.md
Normal file
195
docs/groups.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Конфигурация групп
|
||||
|
||||
## Формат конфигурации
|
||||
|
||||
Конфигурация групп хранится в файле `config/groups.json`.
|
||||
|
||||
**Пример конфигурации:**
|
||||
```json
|
||||
{
|
||||
"monitoring": {
|
||||
"messenger": "telegram",
|
||||
"chat_id": -1001234567890,
|
||||
"thread_id": 0,
|
||||
"config": {}
|
||||
},
|
||||
"alerts_max": {
|
||||
"messenger": "max",
|
||||
"chat_id": "123456789",
|
||||
"thread_id": null,
|
||||
"config": {
|
||||
"access_token": "optional_override_max_token",
|
||||
"api_version": "5.131"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Поля конфигурации:**
|
||||
- `messenger` - тип мессенджера (`telegram`, `max`)
|
||||
- `chat_id` - ID чата (может быть числом для Telegram или строкой для MAX/VK)
|
||||
- `thread_id` - ID треда в группе (только для Telegram, `0` для основной группы, `null` для MAX/VK)
|
||||
- `config` - дополнительная конфигурация для мессенджера (например, `access_token` для MAX/VK)
|
||||
|
||||
## Управление группами через API
|
||||
|
||||
Управление группами доступно через REST API с защитой API ключом.
|
||||
|
||||
### Получение списка поддерживаемых мессенджеров
|
||||
|
||||
```bash
|
||||
GET /api/v1/groups/messengers
|
||||
```
|
||||
|
||||
**Пример ответа:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"messengers": [
|
||||
{
|
||||
"type": "telegram",
|
||||
"name": "Telegram",
|
||||
"supports_threads": true,
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"type": "max",
|
||||
"name": "MAX/VK",
|
||||
"supports_threads": false,
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Получение списка групп
|
||||
|
||||
```bash
|
||||
# Без API ключа (только названия)
|
||||
GET /api/v1/groups
|
||||
|
||||
# С API ключом (полная информация)
|
||||
GET /api/v1/groups
|
||||
Headers: X-API-Key: your_api_key_here
|
||||
```
|
||||
|
||||
**Пример ответа (без API ключа):**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"groups": [
|
||||
{"name": "monitoring", "chat_id": null},
|
||||
{"name": "alerts", "chat_id": null}
|
||||
],
|
||||
"count": 2
|
||||
}
|
||||
```
|
||||
|
||||
**Пример ответа (с API ключом):**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"groups": [
|
||||
{
|
||||
"name": "monitoring",
|
||||
"messenger": "telegram",
|
||||
"chat_id": -1001234567890,
|
||||
"thread_id": 0
|
||||
},
|
||||
{
|
||||
"name": "alerts_max",
|
||||
"messenger": "max",
|
||||
"chat_id": "123456789",
|
||||
"thread_id": null
|
||||
}
|
||||
],
|
||||
"count": 2
|
||||
}
|
||||
```
|
||||
|
||||
### Создание группы
|
||||
|
||||
```bash
|
||||
POST /api/v1/groups
|
||||
Headers:
|
||||
Content-Type: application/json
|
||||
X-API-Key: your_api_key_here
|
||||
|
||||
{
|
||||
"group_name": "monitoring",
|
||||
"messenger": "telegram",
|
||||
"chat_id": -1001234567890,
|
||||
"thread_id": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Пример запроса для MAX/VK:**
|
||||
```bash
|
||||
POST /api/v1/groups
|
||||
Headers:
|
||||
Content-Type: application/json
|
||||
X-API-Key: your_api_key_here
|
||||
|
||||
{
|
||||
"group_name": "max_alerts",
|
||||
"messenger": "max",
|
||||
"chat_id": "123456789",
|
||||
"thread_id": 0,
|
||||
"config": {
|
||||
"access_token": "your_max_access_token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Пример ответа:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "Группа 'monitoring' создана с мессенджером 'telegram' и ID -1001234567890"
|
||||
}
|
||||
```
|
||||
|
||||
### Обновление группы
|
||||
|
||||
```bash
|
||||
PUT /api/v1/groups/{group_name}
|
||||
Headers:
|
||||
Content-Type: application/json
|
||||
X-API-Key: your_api_key_here
|
||||
|
||||
{
|
||||
"messenger": "telegram",
|
||||
"chat_id": -1001234567891,
|
||||
"thread_id": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Пример ответа:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "Группа 'monitoring' обновлена"
|
||||
}
|
||||
```
|
||||
|
||||
### Удаление группы
|
||||
|
||||
```bash
|
||||
DELETE /api/v1/groups/{group_name}
|
||||
Headers: X-API-Key: your_api_key_here
|
||||
```
|
||||
|
||||
**Пример ответа:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "Группа 'monitoring' удалена"
|
||||
}
|
||||
```
|
||||
|
||||
**API ключ для управления группами:**
|
||||
- Настраивается в переменной окружения `API_KEY`
|
||||
- Без API ключа можно только просматривать названия групп
|
||||
- С API ключом доступны все операции (создание, обновление, удаление)
|
||||
- API ключ передается в заголовке: `X-API-Key: your_api_key_here`
|
||||
|
||||
113
docs/jira-mapping.md
Normal file
113
docs/jira-mapping.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Конфигурация Jira Mapping
|
||||
|
||||
## Формат конфигурации
|
||||
|
||||
Конфигурация маппинга Jira хранится в файле `config/jira_mapping.json`. Она определяет, какие тикеты создавать в Jira на основе алертов.
|
||||
|
||||
**Пример конфигурации:**
|
||||
```json
|
||||
{
|
||||
"alertmanager": {
|
||||
"default_project": "MON",
|
||||
"default_assignee": null,
|
||||
"default_issue_type": "Bug",
|
||||
"default_priority": "High",
|
||||
"mappings": [
|
||||
{
|
||||
"conditions": {
|
||||
"severity": "critical",
|
||||
"namespace": "production"
|
||||
},
|
||||
"project": "MON",
|
||||
"assignee": "devops-team@example.com",
|
||||
"issue_type": "Bug",
|
||||
"priority": "Highest",
|
||||
"labels": ["critical", "production", "alertmanager"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"grafana": {
|
||||
"default_project": "MON",
|
||||
"default_assignee": null,
|
||||
"default_issue_type": "Bug",
|
||||
"default_priority": "High",
|
||||
"mappings": []
|
||||
},
|
||||
"zabbix": {
|
||||
"default_project": "MON",
|
||||
"default_assignee": null,
|
||||
"default_issue_type": "Bug",
|
||||
"default_priority": "High",
|
||||
"mappings": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Условия маппинга
|
||||
|
||||
Условия маппинга определяют, когда использовать конкретный маппинг. Поддерживаются следующие условия:
|
||||
|
||||
### Для AlertManager:
|
||||
- `status` - статус алерта (firing, resolved)
|
||||
- `severity` - критичность (critical, warning, info)
|
||||
- `namespace` - namespace Kubernetes
|
||||
- `alertname` - название алерта
|
||||
- Любые другие метки из `commonLabels`
|
||||
|
||||
### Для Grafana:
|
||||
- `state` - состояние алерта (alerting, ok, paused, pending, no_data)
|
||||
- `ruleName` - название правила
|
||||
- `tags` - теги алерта (объект с ключами и значениями)
|
||||
|
||||
### Для Zabbix:
|
||||
- `status` - статус (PROBLEM, OK)
|
||||
- `event-severity` - серьезность события
|
||||
- `event-name` - название события
|
||||
- `host-name` - имя хоста
|
||||
|
||||
**Примеры условий:**
|
||||
```json
|
||||
{
|
||||
"conditions": {
|
||||
"severity": "critical",
|
||||
"namespace": "production"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"conditions": {
|
||||
"state": "alerting",
|
||||
"tags": {
|
||||
"environment": "production"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Приоритет маппингов:**
|
||||
1. Проверяются все маппинги в порядке их определения
|
||||
2. Используется первый маппинг, условия которого совпадают с данными алерта
|
||||
3. Если маппинг не найден, используются значения по умолчанию
|
||||
|
||||
## Автоматическое создание тикетов
|
||||
|
||||
При получении алерта, если интеграция с Jira включена и выполнены условия для создания тикета:
|
||||
|
||||
1. Приложение проверяет конфигурацию маппинга
|
||||
2. Находит подходящий маппинг на основе условий алерта
|
||||
3. Создает тикет в Jira с указанными параметрами
|
||||
4. Добавляет кнопку в Telegram сообщение со ссылкой на созданный тикет
|
||||
|
||||
**Настройка переменных окружения:**
|
||||
- `JIRA_ENABLED=true` - включить интеграцию с Jira
|
||||
- `JIRA_URL=https://jira.example.com` - URL Jira сервера
|
||||
- `JIRA_EMAIL=user@example.com` - Email пользователя Jira
|
||||
- `JIRA_API_TOKEN=your_jira_api_token_here` - API токен Jira
|
||||
- `JIRA_PROJECT_KEY=MON` - Ключ проекта Jira по умолчанию
|
||||
- `JIRA_DEFAULT_ASSIGNEE=user@example.com` - Исполнитель по умолчанию
|
||||
- `JIRA_DEFAULT_ISSUE_TYPE=Bug` - Тип задачи по умолчанию
|
||||
- `JIRA_CREATE_ON_ALERT=true` - Создавать тикет при алерте
|
||||
- `JIRA_CREATE_ON_RESOLVED=false` - Создавать тикет при resolved
|
||||
|
||||
54
docs/messaging.md
Normal file
54
docs/messaging.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Отправка сообщений
|
||||
|
||||
## Как работает отправка
|
||||
|
||||
1. **Получение алерта:**
|
||||
- Система мониторинга (Grafana, Zabbix, AlertManager) отправляет webhook на эндпоинт Message Gateway
|
||||
- Message Gateway валидирует данные алерта через Pydantic модели
|
||||
- Увеличивается счетчик полученных сообщений
|
||||
|
||||
2. **Обработка алерта:**
|
||||
- Проверяются стоп-слова (если алерт заблокирован, сообщение не отправляется)
|
||||
- Загружается конфигурация группы из `config/groups.json`
|
||||
- Определяется мессенджер (Telegram, MAX/VK) из конфигурации группы или параметра запроса
|
||||
|
||||
3. **Формирование сообщения:**
|
||||
- Загружается шаблон сообщения из `templates/`
|
||||
- Шаблон рендерится с данными алерта через Jinja2
|
||||
- Формируются кнопки для перехода к Grafana/Prometheus/Jira
|
||||
|
||||
4. **Создание Jira тикета (опционально):**
|
||||
- Если Jira включена и выполнены условия, создается тикет в Jira
|
||||
- Загружается маппинг из `config/jira_mapping.json`
|
||||
- Находится подходящий маппинг на основе условий алерта
|
||||
- Создается тикет с описанием из шаблона `jira_common.tmpl`
|
||||
- Добавляется кнопка для перехода к тикету в Jira
|
||||
|
||||
5. **Отправка в мессенджер:**
|
||||
- Создается клиент мессенджера через `MessengerFactory`
|
||||
- Проверяется поддержка тредов (для MAX/VK треды не поддерживаются)
|
||||
- Сообщение отправляется в мессенджер через клиент
|
||||
- Увеличивается счетчик отправленных сообщений
|
||||
|
||||
## Поддержка мессенджеров
|
||||
|
||||
### Telegram
|
||||
- ✅ Полная поддержка текстовых сообщений
|
||||
- ✅ Поддержка медиа (фото, видео, аудио, документы)
|
||||
- ✅ Поддержка тредов (threads)
|
||||
- ✅ Поддержка кнопок (InlineKeyboardMarkup)
|
||||
- ✅ Поддержка HTML и Markdown форматирования
|
||||
|
||||
### MAX/VK
|
||||
- ✅ Поддержка текстовых сообщений
|
||||
- ✅ Поддержка медиа (фото, видео, аудио, документы)
|
||||
- ❌ Треды не поддерживаются (thread_id игнорируется)
|
||||
- ✅ Поддержка кнопок (в формате VK API)
|
||||
- ✅ Конвертация HTML в формат VK
|
||||
|
||||
**Различия в отправке:**
|
||||
- Telegram использует Bot API, MAX/VK использует VK API
|
||||
- Telegram поддерживает треды, MAX/VK - нет
|
||||
- Формат кнопок различается (Telegram - InlineKeyboardMarkup, MAX/VK - JSON)
|
||||
- HTML форматирование конвертируется в формат VK для MAX/VK
|
||||
|
||||
121
docs/monitoring/alertmanager.md
Normal file
121
docs/monitoring/alertmanager.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Настройка AlertManager
|
||||
|
||||
## Создание конфигурации AlertManager
|
||||
|
||||
1. **Открытие конфигурации:**
|
||||
- Откройте файл конфигурации AlertManager (обычно `alertmanager.yml`)
|
||||
- Добавьте webhook receiver:
|
||||
|
||||
```yaml
|
||||
receivers:
|
||||
- name: 'message-gateway'
|
||||
webhook_configs:
|
||||
- url: 'http://your-gateway-url/api/v1/alertmanager/{k8s_cluster}/{group_name}/{thread_id}'
|
||||
send_resolved: true
|
||||
http_config:
|
||||
bearer_token: 'optional-bearer-token'
|
||||
```
|
||||
|
||||
2. **Настройка Route:**
|
||||
- В конфигурации AlertManager добавьте route:
|
||||
|
||||
```yaml
|
||||
route:
|
||||
group_by: ['alertname', 'cluster', 'service']
|
||||
group_wait: 10s
|
||||
group_interval: 10s
|
||||
repeat_interval: 12h
|
||||
receiver: 'message-gateway'
|
||||
routes:
|
||||
- match:
|
||||
severity: critical
|
||||
receiver: 'message-gateway'
|
||||
continue: true
|
||||
- match:
|
||||
severity: warning
|
||||
receiver: 'message-gateway'
|
||||
```
|
||||
|
||||
3. **Применение конфигурации:**
|
||||
- Сохраните файл конфигурации
|
||||
- Перезапустите AlertManager или перезагрузите конфигурацию:
|
||||
```bash
|
||||
curl -X POST http://alertmanager:9093/-/reload
|
||||
```
|
||||
|
||||
## Пример конфигурации
|
||||
|
||||
**Полный пример `alertmanager.yml`:**
|
||||
```yaml
|
||||
global:
|
||||
resolve_timeout: 5m
|
||||
|
||||
route:
|
||||
group_by: ['alertname', 'cluster', 'service']
|
||||
group_wait: 10s
|
||||
group_interval: 10s
|
||||
repeat_interval: 12h
|
||||
receiver: 'message-gateway'
|
||||
routes:
|
||||
- match:
|
||||
severity: critical
|
||||
receiver: 'message-gateway'
|
||||
continue: true
|
||||
- match:
|
||||
severity: warning
|
||||
receiver: 'message-gateway'
|
||||
|
||||
receivers:
|
||||
- name: 'message-gateway'
|
||||
webhook_configs:
|
||||
- url: 'http://message-gateway.example.com/api/v1/alertmanager/production/monitoring/0'
|
||||
send_resolved: true
|
||||
```
|
||||
|
||||
**Пример URL для webhook:**
|
||||
```
|
||||
http://message-gateway.example.com/api/v1/alertmanager/production/monitoring/0
|
||||
```
|
||||
|
||||
Где:
|
||||
- `production` - имя Kubernetes кластера (используется для формирования URL к Grafana/Prometheus)
|
||||
- `monitoring` - имя группы из `config/groups.json`
|
||||
- `0` - ID треда (0 для основной группы, поддерживается только для Telegram)
|
||||
|
||||
## Формат данных
|
||||
|
||||
AlertManager отправляет данные в следующем формате:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "firing",
|
||||
"externalURL": "http://alertmanager.example.com",
|
||||
"commonLabels": {
|
||||
"alertname": "HighCPUUsage",
|
||||
"severity": "critical",
|
||||
"namespace": "production",
|
||||
"pod": "app-deployment-7d8f9b4c5-abc123",
|
||||
"container": "app-container"
|
||||
},
|
||||
"commonAnnotations": {
|
||||
"summary": "High CPU usage detected in production namespace",
|
||||
"description": "CPU usage is above 90% for 5 minutes on pod app-deployment-7d8f9b4c5-abc123",
|
||||
"runbook_url": "https://wiki.example.com/runbooks/high-cpu-usage"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Поддерживаемые статусы
|
||||
|
||||
- `firing` - алерт сработал
|
||||
- `resolved` - алерт разрешен
|
||||
|
||||
## Примечания
|
||||
|
||||
- Не требует авторизации (API ключ не нужен)
|
||||
- Если Jira включена, будет автоматически создан тикет в Jira (внутренний процесс)
|
||||
- В сообщении будет добавлена кнопка для перехода к тикету в Jira (если поддерживается мессенджером)
|
||||
- Алерты могут быть заблокированы стоп-словами (настройка в config/stop_words.txt)
|
||||
- URL к Grafana/Prometheus/AlertManager формируется на основе имени кластера
|
||||
- Thread ID поддерживается только для Telegram (для MAX/VK игнорируется)
|
||||
|
||||
91
docs/monitoring/grafana.md
Normal file
91
docs/monitoring/grafana.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Настройка Grafana
|
||||
|
||||
## Создание Webhook Notification Channel
|
||||
|
||||
1. **Открытие настроек:**
|
||||
- Откройте Grafana и перейдите в раздел "Alerting" → "Notification channels"
|
||||
- Нажмите "New channel"
|
||||
|
||||
2. **Настройка webhook:**
|
||||
- Выберите тип "Webhook"
|
||||
- Заполните форму:
|
||||
- **Name:** Message Gateway
|
||||
- **Type:** webhook
|
||||
- **URL:** `http://your-gateway-url/api/v1/grafana/{group_name}/{thread_id}`
|
||||
- Где `{group_name}` - имя группы из `config/groups.json` (например, "monitoring")
|
||||
- Где `{thread_id}` - ID треда (0 для основной группы, поддерживается только для Telegram)
|
||||
- **HTTP Method:** POST
|
||||
- **Send on all alerts:** включено
|
||||
- **Include image:** опционально
|
||||
- Сохраните настройки
|
||||
|
||||
3. **Настройка Alert Rule:**
|
||||
- Создайте или откройте существующее правило алерта
|
||||
- В разделе "Notifications" выберите созданный канал "Message Gateway"
|
||||
- Сохраните правило
|
||||
|
||||
4. **Тестирование:**
|
||||
- Создайте тестовый алерт в Grafana
|
||||
- Проверьте, что сообщение пришло в Telegram/MAX
|
||||
|
||||
**Пример URL для webhook:**
|
||||
```
|
||||
http://message-gateway.example.com/api/v1/grafana/monitoring/0
|
||||
```
|
||||
|
||||
## Пример конфигурации
|
||||
|
||||
**Notification Channel в Grafana:**
|
||||
- **Name:** Message Gateway
|
||||
- **Type:** Webhook
|
||||
- **URL:** `http://message-gateway.example.com/api/v1/grafana/monitoring/0`
|
||||
- **HTTP Method:** POST
|
||||
- **Send on all alerts:** Yes
|
||||
- **Include image:** No
|
||||
|
||||
**Alert Rule:**
|
||||
- В разделе "Notifications" выберите "Message Gateway"
|
||||
- Сохраните правило
|
||||
|
||||
## Формат данных
|
||||
|
||||
Grafana отправляет данные в следующем формате:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "[Alerting] High CPU Usage",
|
||||
"ruleId": 674180201771804383,
|
||||
"ruleName": "High CPU Usage Alert",
|
||||
"state": "alerting",
|
||||
"evalMatches": [
|
||||
{
|
||||
"value": 95.5,
|
||||
"metric": "cpu_usage_percent",
|
||||
"tags": {"host": "server01", "instance": "production"}
|
||||
}
|
||||
],
|
||||
"orgId": 1,
|
||||
"dashboardId": 123,
|
||||
"panelId": 456,
|
||||
"tags": {"severity": "critical", "environment": "production"},
|
||||
"ruleUrl": "http://grafana.cism-ms.ru/alerting/list",
|
||||
"message": "CPU usage is above 90% threshold for more than 5 minutes"
|
||||
}
|
||||
```
|
||||
|
||||
## Поддерживаемые состояния
|
||||
|
||||
- `alerting` - алерт сработал
|
||||
- `ok` - алерт разрешен
|
||||
- `paused` - алерт приостановлен
|
||||
- `pending` - алерт в ожидании
|
||||
- `no_data` - нет данных
|
||||
|
||||
## Примечания
|
||||
|
||||
- Не требует авторизации (API ключ не нужен)
|
||||
- Если Jira включена, будет автоматически создан тикет в Jira (внутренний процесс)
|
||||
- В сообщении будет добавлена кнопка для перехода к тикету в Jira (если поддерживается мессенджером)
|
||||
- URL правила алерта будет добавлен в сообщение
|
||||
- Thread ID поддерживается только для Telegram (для MAX/VK игнорируется)
|
||||
|
||||
107
docs/monitoring/zabbix.md
Normal file
107
docs/monitoring/zabbix.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Настройка Zabbix
|
||||
|
||||
## Создание Media Type
|
||||
|
||||
1. **Открытие настроек:**
|
||||
- Откройте Zabbix и перейдите в раздел "Administration" → "Media types"
|
||||
- Нажмите "Create media type"
|
||||
|
||||
2. **Настройка Media Type:**
|
||||
- Заполните форму:
|
||||
- **Name:** Message Gateway
|
||||
- **Type:** Webhook
|
||||
- **Script name:** message-gateway
|
||||
- **Script parameters:**
|
||||
```
|
||||
{ALERT.SENDTO}
|
||||
{ALERT.SUBJECT}
|
||||
{ALERT.MESSAGE}
|
||||
```
|
||||
- Сохраните настройки
|
||||
|
||||
3. **Создание Action:**
|
||||
- Перейдите в раздел "Configuration" → "Actions" → "Trigger actions"
|
||||
- Нажмите "Create action"
|
||||
- Заполните форму:
|
||||
- **Name:** Send to Message Gateway
|
||||
- **Conditions:** выберите условия (например, "Trigger severity" = "High")
|
||||
- **Operations:** добавьте операцию "Send to Message Gateway"
|
||||
- **Recovery operations:** добавьте операцию "Send to Message Gateway"
|
||||
- Сохраните настройки
|
||||
|
||||
4. **Настройка User Media:**
|
||||
- Откройте пользователя в Zabbix
|
||||
- В разделе "Media" добавьте новый media:
|
||||
- **Type:** Message Gateway
|
||||
- **Send to:** `monitoring/0` (формат: `{group_name}/{thread_id}`)
|
||||
- **When active:** 1-7,00:00-24:00
|
||||
- Сохраните настройки
|
||||
|
||||
## Пример конфигурации
|
||||
|
||||
**Media Type в Zabbix:**
|
||||
- **Name:** Message Gateway
|
||||
- **Type:** Webhook
|
||||
- **Script name:** message-gateway
|
||||
- **Script parameters:**
|
||||
```
|
||||
{ALERT.SENDTO}
|
||||
{ALERT.SUBJECT}
|
||||
{ALERT.MESSAGE}
|
||||
```
|
||||
|
||||
**Action:**
|
||||
- **Name:** Send to Message Gateway
|
||||
- **Conditions:** Trigger severity = High
|
||||
- **Operations:** Send to Message Gateway
|
||||
- **Recovery operations:** Send to Message Gateway
|
||||
|
||||
**User Media:**
|
||||
- **Type:** Message Gateway
|
||||
- **Send to:** `monitoring/0`
|
||||
- **When active:** 1-7,00:00-24:00
|
||||
|
||||
## Формат данных
|
||||
|
||||
Zabbix отправляет данные в следующем формате:
|
||||
|
||||
```json
|
||||
{
|
||||
"link": "https://zabbix.example.com/tr_events.php?triggerid=42667&eventid=8819711",
|
||||
"status": "PROBLEM",
|
||||
"action-id": "7",
|
||||
"alert-subject": "Problem: High CPU utilization (over 90% for 5m)",
|
||||
"alert-message": "Problem started at 16:48:44 on 2024.02.08",
|
||||
"event-id": "8819711",
|
||||
"event-name": "High CPU utilization (over 90% for 5m)",
|
||||
"event-nseverity": "2",
|
||||
"event-opdata": "Current utilization: 95.2 %",
|
||||
"event-severity": "Warning",
|
||||
"host-name": "pnode28",
|
||||
"host-ip": "10.14.253.38",
|
||||
"host-port": "10050"
|
||||
}
|
||||
```
|
||||
|
||||
## Поддерживаемые статусы
|
||||
|
||||
- `PROBLEM` - проблема обнаружена
|
||||
- `OK` - проблема разрешена
|
||||
|
||||
## Уровни серьезности
|
||||
|
||||
- `Disaster` - катастрофа
|
||||
- `High` - высокая
|
||||
- `Warning` - предупреждение
|
||||
- `Average` - средняя
|
||||
- `Information` - информация
|
||||
|
||||
## Примечания
|
||||
|
||||
- Не требует авторизации (API ключ не нужен)
|
||||
- Если Jira включена, будет автоматически создан тикет в Jira (внутренний процесс)
|
||||
- В сообщении будет добавлена кнопка для перехода к тикету в Jira (если поддерживается мессенджером)
|
||||
- Ссылка на событие Zabbix будет добавлена в сообщение
|
||||
- Thread ID поддерживается только для Telegram (для MAX/VK игнорируется)
|
||||
- Можно настроить фильтрацию по серьезности события
|
||||
|
||||
226
docs/templates.md
Normal file
226
docs/templates.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# Шаблоны сообщений
|
||||
|
||||
## Структура шаблонов
|
||||
|
||||
Шаблоны сообщений находятся в папке `templates/` и используют синтаксис Jinja2. Шаблоны разделены на две категории:
|
||||
|
||||
1. **Шаблоны для мессенджеров** - форматирование сообщений для отправки в Telegram, MAX/VK
|
||||
2. **Шаблоны для Jira** - форматирование описания тикетов в Jira
|
||||
|
||||
## Шаблоны для мессенджеров
|
||||
|
||||
### Grafana (`templates/grafana.tmpl`)
|
||||
|
||||
Шаблон для форматирования алертов из Grafana:
|
||||
|
||||
```jinja2
|
||||
{{ alert_icon }} <b>{{ title }}</b>
|
||||
|
||||
{% if status_name is defined %}{{ status_icon }} <b>Критичность</b>: {{ status_name }}{% endif %}
|
||||
|
||||
<b>Подробнее:</b>
|
||||
{{ message }}
|
||||
{% if labels %}
|
||||
👉 <b>Переменные:</b>
|
||||
|
||||
{{ labels }}{% endif %}
|
||||
{% if tags and state !='ok' %}💻 <b>Ответственные:</b>
|
||||
|
||||
{{ tags }}{% endif %}
|
||||
```
|
||||
|
||||
**Доступные переменные:**
|
||||
- `alert_icon` - иконка алерта (🔴, 🟢, 🟡, 🟠, 🔵)
|
||||
- `status_icon` - иконка статуса (💀, ✅, 🐢, 🤷, 🥴)
|
||||
- `status_name` - название статуса
|
||||
- `title` - заголовок алерта
|
||||
- `message` - сообщение алерта
|
||||
- `state` - состояние алерта (alerting, ok, paused, pending, no_data)
|
||||
- `labels` - метрики (evalMatches)
|
||||
- `tags` - теги алерта
|
||||
|
||||
### Zabbix (`templates/zabbix.tmpl`)
|
||||
|
||||
Шаблон для форматирования алертов из Zabbix:
|
||||
|
||||
```jinja2
|
||||
{{ alert_icon }} <b>{{ title }}</b>
|
||||
|
||||
{% if status_name is defined %}{{ status_icon }} <b>Критичность</b>: {{ status_name }} ({{ state }}){% endif %}
|
||||
|
||||
<b>Кратко:</b>
|
||||
{{ subject }}
|
||||
|
||||
<b>Подробнее:</b>
|
||||
{{ message }}
|
||||
👉 <b>Значение:</b>
|
||||
{{ message_data }}
|
||||
|
||||
🌐 <b>Сеть:</b>
|
||||
Хост: {{ label_host }}
|
||||
IP: {{ label_ip }}
|
||||
Порт: {{ label_port }}
|
||||
```
|
||||
|
||||
**Доступные переменные:**
|
||||
- `alert_icon` - иконка алерта
|
||||
- `status_icon` - иконка статуса
|
||||
- `status_name` - название статуса
|
||||
- `title` - название события
|
||||
- `subject` - тема алерта
|
||||
- `message` - сообщение алерта
|
||||
- `message_data` - дополнительные данные события
|
||||
- `label_host` - имя хоста
|
||||
- `label_ip` - IP адрес хоста
|
||||
- `label_port` - порт хоста
|
||||
|
||||
### AlertManager (`templates/alertmanager.tmpl`)
|
||||
|
||||
Шаблон для форматирования алертов из AlertManager:
|
||||
|
||||
```jinja2
|
||||
{{ alert_icon }} <b>{{ alertname }}</b>
|
||||
|
||||
{% if summary is defined %}{{ summary }}{% endif %}
|
||||
|
||||
{% if status_name is defined %}{{ status_icon }} <b>Критичность</b>: {{ status_name }}{% endif %}{% if description is defined %}
|
||||
|
||||
<b>Подробнее:</b>
|
||||
{{ description }}{% endif %}{% if another_annotations != "" %}
|
||||
{{ another_annotations }}{% endif %}
|
||||
|
||||
{% if clustername is defined %}👉 <b>Kubernetes:</b>
|
||||
|
||||
<b>Кластер</b>: {{ clustername }}{% if node is defined %}
|
||||
<b>Нода</b>: {{ node }}{% endif %}{% if ns is defined %}
|
||||
<b>Неймспейс</b>: {{ ns }}{% endif %}{% if deployment is defined %}
|
||||
<b>Деплоймент</b>: {{ deployment }}{% endif %}{% if daemonset is defined %}
|
||||
<b>Демонсет</b>: {{ daemonset }}{% endif %}{% if replicaset is defined %}
|
||||
<b>Репликасет</b>: {{ replicaset }}{% endif %}{% if statefulset is defined %}
|
||||
<b>Стейтфулсет</b>: {{ statefulset }}{% endif %}{% if container is defined %}
|
||||
<b>Контейнер</b>: {{ container }}{% endif %}{% if pod is defined %}
|
||||
<b>Под</b>: {{ pod }}{% endif %}{% if persistentvolumeclaim is defined %}
|
||||
<b>PVC</b>: {{ persistentvolumeclaim }}{% endif %}{% if job_name is defined %}
|
||||
<b>Имя джобы</b>: {{ job_name }}{% endif %}{% if job is defined %}
|
||||
<b>Джоба</b>: {{ job }}{% endif %}{% if reason is defined %}
|
||||
<b>Причина</b>: {{ reason }}{% endif %}{% if endpoint is defined %}
|
||||
<b>Эндпоинт</b>: {{ endpoint }}{% endif %}{% if instance is defined %}
|
||||
<b>Инстанс</b>: {{ instance }}{% endif %}{% if condition is defined %}
|
||||
<b>Состояние</b>: {{ condition }}{% endif %}{% if reason is defined %}
|
||||
<b>Причина</b>: {{ reason }}{% endif %}{% endif %}
|
||||
|
||||
{% if prometheus is defined %}🔍 <b>Прометей:</b>
|
||||
|
||||
<b>Сервер</b>: {{ prometheus }}{% if service is defined %}
|
||||
<b>Сервис</b>: {{ service }}{% endif %}{% if metrics_path is defined %}
|
||||
<b>Метрики</b>: {{ metrics_path }}{% endif %}{% if uid is defined %}
|
||||
<b>UID</b>: {{ uid }}{% endif %}{% if integration is defined %}
|
||||
<b>Integration</b>: {{ integration }}{% endif %}{% if To is defined %}
|
||||
<b>To</b>: {{ integration }}{% endif %}{% endif %}
|
||||
|
||||
{% if another_labels != "" %}
|
||||
🤷 <b>Разное</b>:
|
||||
|
||||
{{ another_labels }}{% endif %}
|
||||
```
|
||||
|
||||
**Доступные переменные:**
|
||||
- `alert_icon` - иконка алерта
|
||||
- `status_icon` - иконка статуса
|
||||
- `status_name` - название статуса
|
||||
- `alertname` - название алерта
|
||||
- `summary` - краткое описание
|
||||
- `description` - подробное описание
|
||||
- `another_annotations` - дополнительные аннотации
|
||||
- `clustername` - имя кластера Kubernetes
|
||||
- `node` - имя ноды
|
||||
- `ns` - namespace
|
||||
- `deployment` - имя deployment
|
||||
- `pod` - имя pod
|
||||
- `container` - имя контейнера
|
||||
- И другие метки из `commonLabels`
|
||||
|
||||
## Шаблоны для Jira
|
||||
|
||||
### Общий шаблон (`templates/jira_common.tmpl`)
|
||||
|
||||
Шаблон для форматирования описания тикетов в Jira:
|
||||
|
||||
```jinja2
|
||||
**Источник:** {{ source }}
|
||||
|
||||
{% if k8s_cluster %}**Kubernetes кластер:** {{ k8s_cluster }}{% endif %}
|
||||
|
||||
---
|
||||
|
||||
## Детали алерта
|
||||
|
||||
{% if source == "alertmanager" %}
|
||||
**Статус:** {{ status }}
|
||||
**Название:** {{ alertname }}
|
||||
**Критичность:** {{ severity }}
|
||||
|
||||
{% if summary %}**Краткое описание:**
|
||||
{{ summary }}
|
||||
{% endif %}
|
||||
|
||||
{% if description %}**Подробное описание:**
|
||||
{{ description }}
|
||||
{% endif %}
|
||||
|
||||
**Метки:**
|
||||
{% for key, value in common_labels.items() %}
|
||||
- *{{ key }}*: {{ value }}
|
||||
{% endfor %}
|
||||
|
||||
**Аннотации:**
|
||||
{% for key, value in common_annotations.items() %}
|
||||
- *{{ key }}*: {{ value }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if source == "grafana" %}
|
||||
**Состояние:** {{ state }}
|
||||
**Правило:** {{ ruleName }}
|
||||
**Заголовок:** {{ title }}
|
||||
|
||||
{% if message %}**Сообщение:**
|
||||
{{ message }}
|
||||
{% endif %}
|
||||
|
||||
{% if tags %}**Теги:**
|
||||
{% for key, value in tags.items() %}
|
||||
- *{{ key }}*: {{ value }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if evalMatches %}**Метрики:**
|
||||
{% for match in evalMatches %}
|
||||
- *{{ match.metric }}*: {{ match.value }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if source == "zabbix" %}
|
||||
**Статус:** {{ status }}
|
||||
**Серьезность:** {{ event_severity }}
|
||||
**Событие:** {{ event_name }}
|
||||
**Хост:** {{ host_name }} ({{ host_ip }}:{{ host_port }})
|
||||
|
||||
**Тема:**
|
||||
{{ alert_subject }}
|
||||
|
||||
**Сообщение:**
|
||||
{{ alert_message }}
|
||||
{% endif %}
|
||||
|
||||
---
|
||||
|
||||
*Тикет создан автоматически системой мониторинга*
|
||||
```
|
||||
|
||||
**Примечания:**
|
||||
- Шаблон поддерживает условное форматирование в зависимости от источника алерта
|
||||
- Для каждого источника доступны свои переменные
|
||||
- Можно создать отдельные шаблоны для каждого источника: `jira_alertmanager.tmpl`, `jira_grafana.tmpl`, `jira_zabbix.tmpl`
|
||||
|
||||
57
env.example
Normal file
57
env.example
Normal file
@@ -0,0 +1,57 @@
|
||||
# Telegram Bot настройки
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
|
||||
TELEGRAM_ENABLED=true
|
||||
|
||||
# MAX/VK настройки
|
||||
MAX_ACCESS_TOKEN=your_max_access_token_here
|
||||
MAX_API_VERSION=5.131
|
||||
MAX_ENABLED=false
|
||||
|
||||
# Общие настройки мессенджеров
|
||||
DEFAULT_MESSENGER=telegram
|
||||
|
||||
# API ключ для авторизации
|
||||
API_KEY=your_api_key_here
|
||||
|
||||
# Пароль для управления группами (устаревший, используется для обратной совместимости)
|
||||
GROUPS_ADMIN_PASSWORD=your_admin_password_here
|
||||
|
||||
# Файлы конфигурации
|
||||
GROUPS_CONFIG_PATH=/app/config/groups.json
|
||||
TEMPLATES_PATH=/app/templates
|
||||
|
||||
# Grafana настройки
|
||||
GRAFANA_URL=http://grafana.example.com
|
||||
|
||||
# Zabbix настройки
|
||||
ZABBIX_URL=https://zabbix.example.com
|
||||
|
||||
# Kubernetes кластер настройки
|
||||
K8S_CLUSTER_GRAFANA_SUBDOMAIN=monitor.example.kube
|
||||
K8S_CLUSTER_PROMETHEUS_SUBDOMAIN=prometheus.example.kube
|
||||
K8S_CLUSTER_ALERTMANAGER_SUBDOMAIN=alert.example.kube
|
||||
|
||||
# Prometheus Pushgateway настройки
|
||||
PUSHGATEWAY_URL=http://pushgateway.example.com:9091
|
||||
PUSHGATEWAY_JOB=MessageGateway
|
||||
|
||||
# OpenTelemetry настройки
|
||||
OTEL_ENABLED=false
|
||||
OTEL_SERVICE_NAME=monitoring-message-gateway
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo.example.com:4318
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL=http/json
|
||||
OTEL_TRACES_EXPORTER=otlp_proto_http
|
||||
OTEL_EXPORTER_OTLP_INSECURE=true
|
||||
OTEL_PYTHON_LOG_CORRELATION=false
|
||||
|
||||
# Jira настройки
|
||||
JIRA_ENABLED=false
|
||||
JIRA_URL=https://jira.example.com
|
||||
JIRA_EMAIL=user@example.com
|
||||
JIRA_API_TOKEN=your_jira_api_token_here
|
||||
JIRA_PROJECT_KEY=MON
|
||||
JIRA_DEFAULT_ASSIGNEE=user@example.com
|
||||
JIRA_DEFAULT_ISSUE_TYPE=Bug
|
||||
JIRA_MAPPING_CONFIG_PATH=/app/config/jira_mapping.json
|
||||
JIRA_CREATE_ON_ALERT=true
|
||||
JIRA_CREATE_ON_RESOLVED=false
|
||||
340
kubernetes.yaml
Normal file
340
kubernetes.yaml
Normal file
@@ -0,0 +1,340 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: message-gateway
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: message-gateway-secret
|
||||
namespace: message-gateway
|
||||
type: Opaque
|
||||
stringData:
|
||||
telegram_bot_token: "" # Установите токен через kubectl create secret или SealedSecret
|
||||
pushgateway_url: "" # URL Pushgateway (опционально)
|
||||
pushgateway_job: "MessageGateway"
|
||||
grafana_url: "" # URL Grafana (опционально)
|
||||
zabbix_url: "" # URL Zabbix (опционально)
|
||||
k8s_cluster_grafana_subdomain: "" # Поддомен Grafana для K8S кластеров (опционально)
|
||||
k8s_cluster_prometheus_subdomain: "" # Поддомен Prometheus для K8S кластеров (опционально)
|
||||
k8s_cluster_alertmanager_subdomain: "" # Поддомен AlertManager для K8S кластеров (опционально)
|
||||
otel_enabled: "false" # Включить OpenTelemetry (true/false)
|
||||
otel_service_name: "monitoring-message-gateway"
|
||||
otel_exporter_otlp_endpoint: "" # Endpoint OpenTelemetry (опционально)
|
||||
otel_exporter_otlp_protocol: "http/json"
|
||||
otel_traces_exporter: "otlp_proto_http"
|
||||
otel_exporter_otlp_insecure: "true"
|
||||
otel_python_log_correlation: "false"
|
||||
groups_admin_password: "" # Пароль для управления группами (опционально)
|
||||
jira_enabled: "false" # Включить интеграцию с Jira (true/false)
|
||||
jira_url: "" # URL Jira (опционально)
|
||||
jira_email: "" # Email пользователя Jira (опционально)
|
||||
jira_api_token: "" # API Token Jira (опционально)
|
||||
jira_project_key: "" # Ключ проекта Jira (опционально)
|
||||
jira_default_assignee: "" # Email исполнителя по умолчанию (опционально)
|
||||
jira_default_issue_type: "Bug" # Тип задачи по умолчанию
|
||||
jira_create_on_alert: "true" # Создавать тикет при алерте (true/false)
|
||||
jira_create_on_resolved: "false" # Создавать тикет при resolved (true/false)
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: message-gateway
|
||||
namespace: message-gateway
|
||||
labels:
|
||||
app: message-gateway
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: message-gateway
|
||||
strategy:
|
||||
rollingUpdate:
|
||||
maxSurge: 25%
|
||||
maxUnavailable: 25%
|
||||
type: RollingUpdate
|
||||
template:
|
||||
metadata:
|
||||
name: message-gateway
|
||||
labels:
|
||||
app: message-gateway
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: cismharbor
|
||||
nodeSelector:
|
||||
gpushare: "false" # Нода с ГПУ (false/true)
|
||||
nodestate: "working" # Состояние ноды (working/new)
|
||||
containers:
|
||||
- name: message-gateway
|
||||
image: hub.cism-ms.ru/library/message-gateway:v0.2.0
|
||||
imagePullPolicy: "Always"
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
env:
|
||||
- name: TELEGRAM_BOT_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: telegram_bot_token
|
||||
- name: GRAFANA_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: grafana_url
|
||||
optional: true
|
||||
- name: ZABBIX_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: zabbix_url
|
||||
optional: true
|
||||
- name: K8S_CLUSTER_GRAFANA_SUBDOMAIN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: k8s_cluster_grafana_subdomain
|
||||
optional: true
|
||||
- name: K8S_CLUSTER_PROMETHEUS_SUBDOMAIN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: k8s_cluster_prometheus_subdomain
|
||||
optional: true
|
||||
- name: K8S_CLUSTER_ALERTMANAGER_SUBDOMAIN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: k8s_cluster_alertmanager_subdomain
|
||||
optional: true
|
||||
- name: PUSHGATEWAY_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: pushgateway_url
|
||||
optional: true
|
||||
- name: PUSHGATEWAY_JOB
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: pushgateway_job
|
||||
optional: true
|
||||
- name: OTEL_ENABLED
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: otel_enabled
|
||||
optional: true
|
||||
- name: OTEL_SERVICE_NAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: otel_service_name
|
||||
optional: true
|
||||
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: otel_exporter_otlp_endpoint
|
||||
optional: true
|
||||
- name: OTEL_EXPORTER_OTLP_PROTOCOL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: otel_exporter_otlp_protocol
|
||||
optional: true
|
||||
- name: OTEL_TRACES_EXPORTER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: otel_traces_exporter
|
||||
optional: true
|
||||
- name: OTEL_EXPORTER_OTLP_INSECURE
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: otel_exporter_otlp_insecure
|
||||
optional: true
|
||||
- name: OTEL_PYTHON_LOG_CORRELATION
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: otel_python_log_correlation
|
||||
optional: true
|
||||
- name: GROUPS_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: groups_admin_password
|
||||
optional: true
|
||||
- name: JIRA_ENABLED
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: jira_enabled
|
||||
optional: true
|
||||
- name: JIRA_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: jira_url
|
||||
optional: true
|
||||
- name: JIRA_EMAIL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: jira_email
|
||||
optional: true
|
||||
- name: JIRA_API_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: jira_api_token
|
||||
optional: true
|
||||
- name: JIRA_PROJECT_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: jira_project_key
|
||||
optional: true
|
||||
- name: JIRA_DEFAULT_ASSIGNEE
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: jira_default_assignee
|
||||
optional: true
|
||||
- name: JIRA_DEFAULT_ISSUE_TYPE
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: jira_default_issue_type
|
||||
optional: true
|
||||
- name: JIRA_CREATE_ON_ALERT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: jira_create_on_alert
|
||||
optional: true
|
||||
- name: JIRA_CREATE_ON_RESOLVED
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: message-gateway-secret
|
||||
key: jira_create_on_resolved
|
||||
optional: true
|
||||
# Liveness проба
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health
|
||||
port: 8000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
successThreshold: 1
|
||||
failureThreshold: 3
|
||||
# Readiness проба
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health/ready
|
||||
port: 8000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
successThreshold: 1
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
volumeMounts:
|
||||
- name: groups-conf
|
||||
mountPath: /app/config/groups.json
|
||||
subPath: groups.json
|
||||
readOnly: true
|
||||
- name: jira-mapping-conf
|
||||
mountPath: /app/config/jira_mapping.json
|
||||
subPath: jira_mapping.json
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: groups-conf
|
||||
configMap:
|
||||
name: message-gateway-groups-configmap
|
||||
- name: jira-mapping-conf
|
||||
configMap:
|
||||
name: message-gateway-jira-mapping-configmap
|
||||
optional: true
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: message-gateway-groups-configmap
|
||||
namespace: message-gateway
|
||||
data:
|
||||
groups.json: |
|
||||
{
|
||||
"kubernetes": -1002108349725,
|
||||
"monitoring": -1001997464975,
|
||||
"oldmonitoring": -1001469966749
|
||||
}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: message-gateway-jira-mapping-configmap
|
||||
namespace: message-gateway
|
||||
data:
|
||||
jira_mapping.json: |
|
||||
{
|
||||
"alertmanager": {
|
||||
"default_project": "MON",
|
||||
"mappings": []
|
||||
},
|
||||
"grafana": {
|
||||
"default_project": "MON",
|
||||
"mappings": []
|
||||
},
|
||||
"zabbix": {
|
||||
"default_project": "MON",
|
||||
"mappings": []
|
||||
}
|
||||
}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: message-gateway-service
|
||||
namespace: message-gateway
|
||||
labels:
|
||||
app: message-gateway
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: message-gateway
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8000
|
||||
targetPort: 8000
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: message-gateway-ingress
|
||||
namespace: message-gateway
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- monitoring.cism-ms.ru
|
||||
secretName: ru-cism-kube-certs
|
||||
rules:
|
||||
- host: "monitoring.cism-ms.ru"
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: message-gateway-service
|
||||
port:
|
||||
number: 8000
|
||||
123
make-wrapper.sh
Executable file
123
make-wrapper.sh
Executable file
@@ -0,0 +1,123 @@
|
||||
#!/bin/bash
|
||||
# Обертка для Makefile для поддержки подкоманд с пробелами
|
||||
# Использование: ./make-wrapper.sh docker build
|
||||
# ./make-wrapper.sh git push origin main
|
||||
# ./make-wrapper.sh k8s apply CONTEXT=prod
|
||||
|
||||
set -e
|
||||
|
||||
# Цвета для вывода
|
||||
COLOR_RESET='\033[0m'
|
||||
COLOR_BOLD='\033[1m'
|
||||
COLOR_CYAN='\033[36m'
|
||||
COLOR_YELLOW='\033[33m'
|
||||
|
||||
# Функция для вывода помощи
|
||||
show_help() {
|
||||
echo -e "${COLOR_BOLD}${COLOR_CYAN}Makefile Wrapper - Поддержка подкоманд${COLOR_RESET}"
|
||||
echo ""
|
||||
echo "Использование:"
|
||||
echo " ./make-wrapper.sh docker build"
|
||||
echo " ./make-wrapper.sh docker tag"
|
||||
echo " ./make-wrapper.sh docker push"
|
||||
echo " ./make-wrapper.sh git status"
|
||||
echo " ./make-wrapper.sh git pull"
|
||||
echo " ./make-wrapper.sh git push origin main"
|
||||
echo " ./make-wrapper.sh git add file1 file2"
|
||||
echo " ./make-wrapper.sh git commit \"message\""
|
||||
echo " ./make-wrapper.sh k8s contexts"
|
||||
echo " ./make-wrapper.sh k8s apply CONTEXT=prod"
|
||||
echo " ./make-wrapper.sh env check"
|
||||
echo " ./make-wrapper.sh env create"
|
||||
echo ""
|
||||
echo "Или используйте напрямую:"
|
||||
echo " make docker CMD=build"
|
||||
echo " make git CMD=push ARGS=origin main"
|
||||
echo " make k8s CMD=apply CONTEXT=prod"
|
||||
echo " make env CMD=check"
|
||||
}
|
||||
|
||||
# Проверка аргументов
|
||||
if [ $# -eq 0 ]; then
|
||||
show_help
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Получаем команду (docker, git, k8s, env)
|
||||
CMD_TYPE=$1
|
||||
shift
|
||||
|
||||
# Обработка команд
|
||||
case "$CMD_TYPE" in
|
||||
docker)
|
||||
if [ $# -eq 0 ]; then
|
||||
make docker
|
||||
else
|
||||
SUB_CMD=$1
|
||||
shift
|
||||
make docker CMD="$SUB_CMD" "$@"
|
||||
fi
|
||||
;;
|
||||
git)
|
||||
if [ $# -eq 0 ]; then
|
||||
make git
|
||||
else
|
||||
SUB_CMD=$1
|
||||
shift
|
||||
# Для git commit и git add нужны аргументы
|
||||
if [ "$SUB_CMD" = "commit" ] || [ "$SUB_CMD" = "add" ]; then
|
||||
ARGS="$@"
|
||||
make git CMD="$SUB_CMD" ARGS="$ARGS"
|
||||
else
|
||||
# Для остальных команд аргументы передаются как есть
|
||||
make git CMD="$SUB_CMD" ARGS="$@"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
k8s)
|
||||
if [ $# -eq 0 ]; then
|
||||
make k8s
|
||||
else
|
||||
SUB_CMD=$1
|
||||
shift
|
||||
# Обработка CONTEXT=value
|
||||
CONTEXT_ARG=""
|
||||
ARGS=""
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == CONTEXT=* ]]; then
|
||||
CONTEXT_ARG="$arg"
|
||||
else
|
||||
ARGS="$ARGS $arg"
|
||||
fi
|
||||
done
|
||||
if [ -n "$CONTEXT_ARG" ]; then
|
||||
make k8s CMD="$SUB_CMD" $CONTEXT_ARG "$ARGS"
|
||||
else
|
||||
make k8s CMD="$SUB_CMD" "$ARGS"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
env)
|
||||
if [ $# -eq 0 ]; then
|
||||
make env
|
||||
else
|
||||
SUB_CMD=$1
|
||||
shift
|
||||
# Для create-force нужно обработать
|
||||
if [ "$SUB_CMD" = "create-force" ]; then
|
||||
make env-create-force
|
||||
else
|
||||
make env CMD="$SUB_CMD" "$@"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
help|--help|-h)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
# Если это не подкоманда, передаем в make напрямую
|
||||
make "$CMD_TYPE" "$@"
|
||||
;;
|
||||
esac
|
||||
|
||||
35
requirements.txt
Normal file
35
requirements.txt
Normal file
@@ -0,0 +1,35 @@
|
||||
# Main
|
||||
uvicorn[standard]==0.25.0
|
||||
fastapi[all]==0.105.0
|
||||
|
||||
# FastApi packages
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
jinja2==3.1.2
|
||||
|
||||
# Telegram
|
||||
python-telegram-bot==20.7
|
||||
|
||||
# Prometheus
|
||||
prometheus-client==0.19.0
|
||||
|
||||
# Async file operations
|
||||
aiofiles==23.2.1
|
||||
|
||||
# Traces
|
||||
opentelemetry-distro==0.43b0
|
||||
opentelemetry-api==1.22.0
|
||||
opentelemetry-sdk==1.22.0
|
||||
opentelemetry-exporter-otlp==1.22.0
|
||||
opentelemetry-instrumentation-fastapi==0.43b0
|
||||
|
||||
# Logging
|
||||
opentelemetry-instrumentation-logging==0.43b0
|
||||
|
||||
# Jira
|
||||
jira==3.5.2
|
||||
|
||||
# Testing
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
httpx==0.25.2
|
||||
43
templates/alertmanager.tmpl
Normal file
43
templates/alertmanager.tmpl
Normal file
@@ -0,0 +1,43 @@
|
||||
{{ alert_icon }} <b>{{ alertname }}</b>
|
||||
|
||||
{% if summary is defined %}{{ summary }}{% endif %}
|
||||
|
||||
{% if status_name is defined %}{{ status_icon }} <b>Критичность</b>: {{ status_name }}{% endif %}{% if description is defined %}
|
||||
|
||||
<b>Подробнее:</b>
|
||||
{{ description }}{% endif %}{% if another_annotations != "" %}
|
||||
{{ another_annotations }}{% endif %}
|
||||
|
||||
{% if clustername is defined %}👉 <b>Kubernetes:</b>
|
||||
|
||||
<b>Кластер</b>: {{ clustername }}{% if node is defined %}
|
||||
<b>Нода</b>: {{ node }}{% endif %}{% if ns is defined %}
|
||||
<b>Неймспейс</b>: {{ ns }}{% endif %}{% if deployment is defined %}
|
||||
<b>Деплоймент</b>: {{ deployment }}{% endif %}{% if daemonset is defined %}
|
||||
<b>Демонсет</b>: {{ daemonset }}{% endif %}{% if replicaset is defined %}
|
||||
<b>Репликасет</b>: {{ replicaset }}{% endif %}{% if statefulset is defined %}
|
||||
<b>Стейтфулсет</b>: {{ statefulset }}{% endif %}{% if container is defined %}
|
||||
<b>Контейнер</b>: {{ container }}{% endif %}{% if pod is defined %}
|
||||
<b>Под</b>: {{ pod }}{% endif %}{% if persistentvolumeclaim is defined %}
|
||||
<b>PVC</b>: {{ persistentvolumeclaim }}{% endif %}{% if job_name is defined %}
|
||||
<b>Имя джобы</b>: {{ job_name }}{% endif %}{% if job is defined %}
|
||||
<b>Джоба</b>: {{ job }}{% endif %}{% if reason is defined %}
|
||||
<b>Причина</b>: {{ reason }}{% endif %}{% if endpoint is defined %}
|
||||
<b>Эндпоинт</b>: {{ endpoint }}{% endif %}{% if instance is defined %}
|
||||
<b>Инстанс</b>: {{ instance }}{% endif %}{% if condition is defined %}
|
||||
<b>Состояние</b>: {{ condition }}{% endif %}{% if reason is defined %}
|
||||
<b>Причина</b>: {{ reason }}{% endif %}{% endif %}
|
||||
|
||||
{% if prometheus is defined %}🔍 <b>Прометей:</b>
|
||||
|
||||
<b>Сервер</b>: {{ prometheus }}{% if service is defined %}
|
||||
<b>Сервис</b>: {{ service }}{% endif %}{% if metrics_path is defined %}
|
||||
<b>Метрики</b>: {{ metrics_path }}{% endif %}{% if uid is defined %}
|
||||
<b>UID</b>: {{ uid }}{% endif %}{% if integration is defined %}
|
||||
<b>Integration</b>: {{ integration }}{% endif %}{% if To is defined %}
|
||||
<b>To</b>: {{ integration }}{% endif %}{% endif %}
|
||||
|
||||
{% if another_labels != "" %}
|
||||
🤷 <b>Разное</b>:
|
||||
|
||||
{{ another_labels }}{% endif %}
|
||||
13
templates/grafana.tmpl
Normal file
13
templates/grafana.tmpl
Normal file
@@ -0,0 +1,13 @@
|
||||
{{ alert_icon }} <b>{{ title }}</b>
|
||||
|
||||
{% if status_name is defined %}{{ status_icon }} <b>Критичность</b>: {{ status_name }}{% endif %}
|
||||
|
||||
<b>Подробнее:</b>
|
||||
{{ message }}
|
||||
{% if labels %}
|
||||
👉 <b>Переменные:</b>
|
||||
|
||||
{{ labels }}{% endif %}
|
||||
{% if tags and state !='ok' %}💻 <b>Ответственные:</b>
|
||||
|
||||
{{ tags }}{% endif %}
|
||||
71
templates/jira_common.tmpl
Normal file
71
templates/jira_common.tmpl
Normal file
@@ -0,0 +1,71 @@
|
||||
**Источник:** {{ source }}
|
||||
|
||||
{% if k8s_cluster %}**Kubernetes кластер:** {{ k8s_cluster }}{% endif %}
|
||||
|
||||
---
|
||||
|
||||
## Детали алерта
|
||||
|
||||
{% if source == "alertmanager" %}
|
||||
**Статус:** {{ status }}
|
||||
**Название:** {{ alertname }}
|
||||
**Критичность:** {{ severity }}
|
||||
|
||||
{% if summary %}**Краткое описание:**
|
||||
{{ summary }}
|
||||
{% endif %}
|
||||
|
||||
{% if description %}**Подробное описание:**
|
||||
{{ description }}
|
||||
{% endif %}
|
||||
|
||||
**Метки:**
|
||||
{% for key, value in common_labels.items() %}
|
||||
- *{{ key }}*: {{ value }}
|
||||
{% endfor %}
|
||||
|
||||
**Аннотации:**
|
||||
{% for key, value in common_annotations.items() %}
|
||||
- *{{ key }}*: {{ value }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if source == "grafana" %}
|
||||
**Состояние:** {{ state }}
|
||||
**Правило:** {{ ruleName }}
|
||||
**Заголовок:** {{ title }}
|
||||
|
||||
{% if message %}**Сообщение:**
|
||||
{{ message }}
|
||||
{% endif %}
|
||||
|
||||
{% if tags %}**Теги:**
|
||||
{% for key, value in tags.items() %}
|
||||
- *{{ key }}*: {{ value }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if evalMatches %}**Метрики:**
|
||||
{% for match in evalMatches %}
|
||||
- *{{ match.metric }}*: {{ match.value }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if source == "zabbix" %}
|
||||
**Статус:** {{ status }}
|
||||
**Серьезность:** {{ event_severity }}
|
||||
**Событие:** {{ event_name }}
|
||||
**Хост:** {{ host_name }} ({{ host_ip }}:{{ host_port }})
|
||||
|
||||
**Тема:**
|
||||
{{ alert_subject }}
|
||||
|
||||
**Сообщение:**
|
||||
{{ alert_message }}
|
||||
{% endif %}
|
||||
|
||||
---
|
||||
|
||||
*Тикет создан автоматически системой мониторинга*
|
||||
|
||||
16
templates/zabbix.tmpl
Normal file
16
templates/zabbix.tmpl
Normal file
@@ -0,0 +1,16 @@
|
||||
{{ alert_icon }} <b>{{ title }}</b>
|
||||
|
||||
{% if status_name is defined %}{{ status_icon }} <b>Критичность</b>: {{ status_name }} ({{ state }}){% endif %}
|
||||
|
||||
<b>Кратко:</b>
|
||||
{{ subject }}
|
||||
|
||||
<b>Подробнее:</b>
|
||||
{{ message }}
|
||||
👉 <b>Значение:</b>
|
||||
{{ message_data }}
|
||||
|
||||
🌐 <b>Сеть:</b>
|
||||
Хост: {{ label_host }}
|
||||
IP: {{ label_ip }}
|
||||
Порт: {{ label_port }}
|
||||
Reference in New Issue
Block a user