Research-Stack/4-Infrastructure/auto/lib/alerting.py
2026-05-25 16:24:21 -05:00

175 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Alerting: email via local postfix, optional webhook, optional kuma push."""
from __future__ import annotations
import json
import os
import shlex
import subprocess
import sys
from datetime import datetime, timezone
from email.mime.text import MIMEText
from pathlib import Path
from typing import Any
class Alerter:
def __init__(self, config: dict[str, Any]) -> None:
alert_cfg = config.get("alerting", {})
email_cfg = alert_cfg.get("email", {})
webhook_cfg = alert_cfg.get("webhook", {})
dashboard_cfg = alert_cfg.get("dashboard", {})
self.email_enabled = email_cfg.get("enabled", False)
self.email_to = email_cfg.get("to", "")
self.email_relay_ssh_target = email_cfg.get(
"relay_ssh_target",
os.environ.get("RS_ALERT_RELAY_SSH_TARGET", ""),
)
self.sendmail_path = email_cfg.get("sendmail_path", "/usr/sbin/sendmail")
self.webhook_enabled = webhook_cfg.get("enabled", False)
self.webhook_url = webhook_cfg.get("url", "")
self.dashboard_enabled = dashboard_cfg.get("enabled", False)
self.dashboard_endpoint = dashboard_cfg.get("endpoint", "")
self.alerts: list[str] = []
self.warnings: list[str] = []
self.info: list[str] = []
def alert(self, msg: str) -> None:
self.alerts.append(msg)
def warn(self, msg: str) -> None:
self.warnings.append(msg)
def note(self, msg: str) -> None:
self.info.append(msg)
def _is_actionable(self) -> bool:
return bool(self.alerts or self.warnings)
def send(self, tick: int = 0) -> dict[str, Any]:
"""
Dispatch all queued messages. Returns dispatch status dict.
Only sends real alerts/warnings; info-level is logged to receipt only.
"""
result: dict[str, Any] = {"email": False, "webhook": False, "dashboard": False}
if self.email_enabled and self._is_actionable():
result["email"] = self._send_email(tick)
if self.webhook_enabled and self.webhook_url:
result["webhook"] = self._send_webhook(tick)
if self.dashboard_enabled and self.dashboard_endpoint:
result["dashboard"] = self._push_dashboard(tick)
return result
def _build_body(self, tick: int) -> str:
lines = [
f"Subject: infra-controller report — tick {tick}",
f"Time: {datetime.now(timezone.utc).isoformat()}",
f"",
]
if self.alerts:
lines.append(f"--- ALERTS ({len(self.alerts)}) ---")
lines.extend(f" CRIT: {a}" for a in self.alerts)
lines.append("")
if self.warnings:
lines.append(f"--- WARNINGS ({len(self.warnings)}) ---")
lines.extend(f" WARN: {w}" for w in self.warnings)
lines.append("")
if self.info:
lines.append(f"--- INFO ({len(self.info)}) ---")
lines.extend(f" {i}" for i in self.info)
return "\n".join(lines)
def _send_email(self, tick: int) -> bool:
try:
msg = MIMEText(self._build_body(tick))
msg["Subject"] = f"infra-controller tick {tick}"
msg["From"] = "infra-controller@researchstack.info"
msg["To"] = self.email_to
if self.email_relay_ssh_target:
remote = (
f"ALERT_TO={shlex.quote(self.email_to)} "
"send-alert "
f"{shlex.quote(msg['Subject'])} "
f"{shlex.quote(self._build_body(tick))}"
)
proc = subprocess.run(
[
"ssh",
"-o", "StrictHostKeyChecking=no",
self.email_relay_ssh_target,
remote,
],
timeout=30,
capture_output=True,
)
return proc.returncode == 0
proc = subprocess.run(
[self.sendmail_path, "-t", "-oi"],
input=msg.as_string().encode(),
timeout=15,
capture_output=True,
)
return proc.returncode == 0
except Exception as exc:
print(f" alerting: email failed: {exc}", file=sys.stderr)
return False
def _send_webhook(self, tick: int) -> bool:
try:
import subprocess
payload = {
"tick": tick,
"alerts": self.alerts,
"warnings": self.warnings,
"info": self.info,
}
subprocess.run(
[
"curl", "-s", "-X", "POST",
"-H", "Content-Type: application/json",
"-d", json.dumps(payload),
self.webhook_url,
],
timeout=10,
capture_output=True,
)
return True
except Exception as exc:
print(f" alerting: webhook failed: {exc}", file=sys.stderr)
return False
def _push_dashboard(self, tick: int) -> bool:
try:
status = "up"
if self.alerts:
status = "down"
elif self.warnings:
status = "down"
msg = f"{status} — tick {tick}"
if self.alerts:
msg += " | " + "; ".join(self.alerts[:2])
elif self.warnings:
msg += " | " + "; ".join(self.warnings[:2])
subprocess.run(
[
"curl", "-s", "-X", "POST",
"-d", f"status={status}&msg={msg}",
self.dashboard_endpoint,
],
timeout=10,
capture_output=True,
)
return True
except Exception as exc:
print(f" alerting: dashboard push failed: {exc}", file=sys.stderr)
return False