- FastAPI приложение для отправки мониторинговых алертов в мессенджеры - Поддержка Telegram и MAX/VK - Интеграция с Grafana, Zabbix, AlertManager - Автоматическое создание тикетов в Jira - Управление группами мессенджеров через API - Декораторы для авторизации и скрытия эндпоинтов - Подробная документация в папке docs/ Автор: Сергей Антропов Сайт: https://devops.org.ru
312 lines
13 KiB
Python
312 lines
13 KiB
Python
"""
|
||
Модуль для обработки алертов из 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
|