Files
ns8-backup-monitor/ns8_backup_monitor/notifier.py
T

205 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""
notifier.py - Sends a single classified notification email via ns8-sendmail.
Delivery strategy:
1. ns8-sendmail -t (uses NS8 configured relay - preferred)
2. sendmail -t (fallback if ns8-sendmail not found)
No SMTP configuration needed: ns8-sendmail already knows the relay.
"""
import logging
import shutil
import subprocess
from datetime import datetime, timezone
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional
log = logging.getLogger(__name__)
OUTCOME_LABEL = {
"SUCCESS": "OK",
"PARTIAL": "WARNING",
"REPO_FAILURE": "CRITICAL",
}
OUTCOME_COLOR = {
"SUCCESS": "#2e7d32",
"PARTIAL": "#e65100",
"REPO_FAILURE": "#b71c1c",
}
def _sendmail_binary() -> str:
"""Return the path to ns8-sendmail, falling back to sendmail."""
for cmd in ("ns8-sendmail", "sendmail"):
path = shutil.which(cmd)
if path:
return path
raise FileNotFoundError("Neither ns8-sendmail nor sendmail found in PATH")
def _build_text(correlation: dict, repo_status: Optional[dict]) -> str:
outcome = correlation["outcome"]
lines = [
f"NS8 Backup Monitor - {OUTCOME_LABEL[outcome]}: {outcome}",
f"Time: {datetime.now(timezone.utc).isoformat()}",
f"Plans checked: {', '.join(correlation.get('backup_ids', []))}",
f"Modules: {correlation['succeeded']} OK / {correlation['failed']} FAILED / {correlation['total']} total",
"",
]
if correlation["failed_modules"]:
lines.append("Failed modules:")
for m in correlation["failed_modules"]:
lines.append(f" - [{m['module_id']}] backup_id={m['backup_id']}: {m.get('error', 'unknown error')}")
lines.append("")
if repo_status:
lines.append("Repository check:")
for dest in repo_status.get("destinations", []):
lines.append(f" - [{dest['repo_id']}] {dest['status']}: {dest.get('error', '')}")
if repo_status.get("note"):
lines.append(f" NOTE: {repo_status['note']}")
lines.append("")
if correlation.get("note"):
lines.append(f"Note: {correlation['note']}")
return "\n".join(lines)
def _build_html(correlation: dict, repo_status: Optional[dict]) -> str:
outcome = correlation["outcome"]
color = OUTCOME_COLOR[outcome]
label = OUTCOME_LABEL[outcome]
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
plan_ids = ", ".join(correlation.get("backup_ids", [])) or "N/A"
rows = ""
for m in correlation.get("modules", []):
bg = "#e8f5e9" if m["result"] == "success" else "#ffebee"
icon = "✓" if m["result"] == "success" else "✗"
rows += (
f'<tr style="background:{bg}">'
f'<td style="padding:4px 8px">{icon}</td>'
f'<td style="padding:4px 8px">{m["module_id"]}</td>'
f'<td style="padding:4px 8px">{m["backup_id"]}</td>'
f'<td style="padding:4px 8px">{m.get("timestamp", "")}</td>'
f'<td style="padding:4px 8px">{m.get("error", "") or ""}</td>'
'</tr>'
)
repo_section = ""
if repo_status:
repo_rows = ""
for dest in repo_status.get("destinations", []):
bg = "#e8f5e9" if dest["status"] == "OK" else "#ffebee"
repo_rows += (
f'<tr style="background:{bg}">'
f'<td style="padding:4px 8px">{dest["repo_id"]}</td>'
f'<td style="padding:4px 8px"><b>{dest["status"]}</b></td>'
f'<td style="padding:4px 8px">{dest.get("error", "")}</td>'
'</tr>'
)
if repo_rows:
repo_section = f"""
<h3 style="margin-top:24px">Repository check</h3>
<table border="1" cellspacing="0" cellpadding="0" style="border-collapse:collapse;font-size:13px;width:100%">
<thead><tr style="background:#f5f5f5">
<th style="padding:4px 8px">Repo ID</th>
<th style="padding:4px 8px">Status</th>
<th style="padding:4px 8px">Detail</th>
</tr></thead>
<tbody>{repo_rows}</tbody>
</table>
"""
if repo_status.get("note"):
repo_section += f'<p style="color:#777;font-size:12px">{repo_status["note"]}</p>'
return f"""
<html><body style="font-family:monospace;font-size:14px;max-width:800px;margin:auto">
<div style="background:{color};color:#fff;padding:16px 20px;border-radius:6px 6px 0 0">
<b>NS8 Backup Monitor &mdash; {label}: {outcome}</b><br>
<small>{ts} &bull; Plans: {plan_ids}</small>
</div>
<div style="border:1px solid #ddd;border-top:none;padding:16px 20px;border-radius:0 0 6px 6px">
<p>
<b>{correlation['succeeded']}</b> module(s) OK &nbsp;|&nbsp;
<b>{correlation['failed']}</b> FAILED &nbsp;|&nbsp;
<b>{correlation['total']}</b> total
</p>
<table border="1" cellspacing="0" cellpadding="0" style="border-collapse:collapse;font-size:13px;width:100%">
<thead><tr style="background:#f5f5f5">
<th style="padding:4px 8px"></th>
<th style="padding:4px 8px">Module</th>
<th style="padding:4px 8px">Backup ID</th>
<th style="padding:4px 8px">Timestamp</th>
<th style="padding:4px 8px">Error</th>
</tr></thead>
<tbody>{rows}</tbody>
</table>
{repo_section}
</div>
</body></html>
"""
def send_notification(
config: dict,
alerts: list,
correlation: dict,
repo_status: Optional[dict] = None
):
outcome = correlation["outcome"]
mail_cfg = config.get("mail", {})
subject_prefix = mail_cfg.get("subject_prefix", "[NS8 Backup]")
subject = (
f"{subject_prefix} {OUTCOME_LABEL[outcome]}: {outcome} - "
f"{datetime.now(timezone.utc).strftime('%Y-%m-%d')}"
)
mail_to = mail_cfg.get("to", [])
if not mail_to:
log.error("No mail.to recipients configured - cannot send notification")
return
mail_from = mail_cfg.get("from", "")
text_body = _build_text(correlation, repo_status)
html_body = _build_html(correlation, repo_status)
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["To"] = ", ".join(mail_to)
if mail_from:
msg["From"] = mail_from
msg.attach(MIMEText(text_body, "plain"))
msg.attach(MIMEText(html_body, "html"))
try:
binary = _sendmail_binary()
except FileNotFoundError as e:
log.error(str(e))
return
try:
proc = subprocess.run(
[binary, "-t"],
input=msg.as_bytes(),
capture_output=True,
timeout=30,
)
if proc.returncode != 0:
log.error(
f"ns8-sendmail exited {proc.returncode}: {proc.stderr.decode().strip()}"
)
else:
log.info(f"Notification sent via {binary}: {subject} -> {mail_to}")
except subprocess.TimeoutExpired:
log.error(f"ns8-sendmail timed out after 30s")
except Exception as e:
log.error(f"Failed to invoke {binary}: {e}")