Files
FabledSteward/steward/core/notifications.py
T
bvandeusenandClaude Opus 5.5 0b787a0676
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m48s
CI / publish (push) Successful in 1m8s
fix(alerts): an alert on a count says how many of what, and names them (#6400)
"swarm services short on swarm is 6." becomes "6 swarm services are
short of their replicas.", with a Which line naming each service and how
many of its tasks run. The swarm figures get labels, and the counting
figures (swarm, mounts, drives, updates) read as sentences in the email,
its subject and the Alerts page. A plugin names what its count counts
through core.alerts.names_behind; the Docker plugin does for the three
swarm figures, from the stored swarm. Webhooks get alert.which too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:00:56 -04:00

114 lines
4.0 KiB
Python

from __future__ import annotations
import json
import logging
import smtplib
import ssl
import types
import httpx
from jinja2.sandbox import SandboxedEnvironment
logger = logging.getLogger(__name__)
async def dispatch_notifications(webhook_cfg: dict, alert_data: dict) -> dict:
"""Post an alert's webhook when one is set; {"webhook": result} or {}.
Email goes through the outbox instead (core.outbox, #5506)."""
results = {}
if webhook_cfg.get("url"):
results["webhook"] = await _send_webhook(webhook_cfg, alert_data)
return results
SMTP_TIMEOUT_S = 15
def smtp_mode(port, tls) -> str:
"""How to talk to the SMTP server: "ssl" (implicit TLS, port 465),
"starttls" (upgrade a plain connection, when TLS is ticked) or "plain"."""
if int(port or 0) == 465:
return "ssl"
return "starttls" if tls else "plain"
def smtp_open(cfg: dict) -> smtplib.SMTP:
"""A connection to the mail server, TLS and login done, every step
bounded by SMTP_TIMEOUT_S. The caller closes it (use it in `with`).
Only core.outbox.deliver_sync opens one: every email is a queued row."""
port = int(cfg.get("port") or 587)
mode = smtp_mode(port, cfg.get("tls", True))
ctx = ssl.create_default_context()
if mode == "ssl":
smtp = smtplib.SMTP_SSL(cfg["host"], port, timeout=SMTP_TIMEOUT_S, context=ctx)
else:
smtp = smtplib.SMTP(cfg["host"], port, timeout=SMTP_TIMEOUT_S)
try:
if mode == "starttls":
smtp.starttls(context=ctx)
if cfg.get("username"):
smtp.login(cfg["username"], cfg.get("password", ""))
except BaseException:
smtp.close()
raise
return smtp
def _json_str(value) -> str:
"""A value as it may sit between the quotes of a JSON string."""
return json.dumps("" if value is None else str(value))[1:-1]
def render_webhook(template_str: str, alert: dict) -> str:
"""The webhook body for an alert, checked to be JSON.
In the template, `alert.<field>` is escaped to sit inside a JSON string
("{{ alert.rule_name }}" stays valid whatever the name holds), with a
missing value reading "no data" and a list (`which`) its items joined by
", "; `raw.<field>` is the plain value for use with |tojson
({{ raw.value | tojson }} gives a number or null, {{ raw.which | tojson }}
an array). The template runs sandboxed. ValueError says why a body could
not be made.
"""
shown = {k: ", ".join(map(str, v)) if isinstance(v, list) else v for k, v in alert.items()}
if shown.get("value") is None:
shown["value"] = "no data"
env = SandboxedEnvironment()
try:
rendered = env.from_string(template_str).render(
alert=types.SimpleNamespace(**{k: _json_str(v) for k, v in shown.items()}),
raw=types.SimpleNamespace(**alert))
except Exception as exc:
raise ValueError(f"Template render failed: {exc}") from None
try:
json.loads(rendered)
except json.JSONDecodeError as exc:
raise ValueError(f"Rendered webhook template is not valid JSON: {exc}") from None
return rendered
async def _send_webhook(cfg: dict, alert: dict) -> dict:
template_str = cfg.get("template") or '{"content": "{{ alert.state }} — {{ alert.rule_name }}"}'
try:
rendered = render_webhook(template_str, alert)
except ValueError as exc:
logger.error(str(exc))
return {"sent": False, "error": str(exc)}
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(
cfg["url"],
content=rendered,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
return {"sent": True, "error": None}
except Exception as exc:
# The URL is a secret (it usually carries its own token) and httpx
# puts it in its error messages.
error = str(exc).replace(cfg["url"], "<webhook URL>")
logger.error(f"Webhook notification failed: {error}")
return {"sent": False, "error": error}