2026-05-18 15:13:06 +00:00
|
|
|
#!/usr/bin/env python3
|
2026-05-18 21:55:27 +00:00
|
|
|
"""Compose and send the backup status notification email.
|
|
|
|
|
|
|
|
|
|
This module builds a structured plain-text email and delivers it using
|
|
|
|
|
``ns8-sendmail``, the NS8 system mail sender available on the cluster leader.
|
|
|
|
|
|
|
|
|
|
Why ``ns8-sendmail`` instead of smtplib
|
|
|
|
|
-----------------------------------------
|
|
|
|
|
NS8 manages SMTP relay configuration centrally in the cluster. Using
|
|
|
|
|
``ns8-sendmail`` means the email is sent through whatever relay the
|
|
|
|
|
administrator has configured (internal Postfix, external SMTP relay, etc.)
|
|
|
|
|
without duplicating that configuration in this tool. Direct smtplib calls
|
|
|
|
|
would require re-reading and re-implementing NS8's relay settings.
|
|
|
|
|
|
|
|
|
|
Email structure
|
|
|
|
|
---------------
|
|
|
|
|
The email is plain text with three sections:
|
|
|
|
|
|
|
|
|
|
1. SUMMARY - Overall outcome (SUCCESS / PARTIAL / REPO_FAILURE),
|
|
|
|
|
timestamp, and list of evaluated backup plan IDs.
|
|
|
|
|
|
|
|
|
|
2. MODULE STATUS TABLE - One row per backup module showing module_id,
|
|
|
|
|
backup_id, result, and any error message.
|
|
|
|
|
Absent on SUCCESS to keep the email concise.
|
|
|
|
|
|
|
|
|
|
3. REPOSITORY DIAGNOSTICS - Per-destination restic check results.
|
|
|
|
|
Absent on SUCCESS (repo check is skipped).
|
|
|
|
|
|
|
|
|
|
Subject line format
|
|
|
|
|
--------------------
|
|
|
|
|
[ns8-backup] SUCCESS - all 4 modules backed up successfully
|
|
|
|
|
[ns8-backup] PARTIAL - 1/4 modules failed
|
|
|
|
|
[ns8-backup] REPO_FAILURE - no backup status found (possible repo issue)
|
2026-05-18 15:13:06 +00:00
|
|
|
"""
|
2026-05-18 21:04:12 +00:00
|
|
|
|
2026-05-18 15:13:06 +00:00
|
|
|
import logging
|
2026-05-18 20:06:01 +00:00
|
|
|
import subprocess
|
2026-05-18 15:13:06 +00:00
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
2026-05-18 21:04:12 +00:00
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-05-18 21:55:27 +00:00
|
|
|
# Subject builder
|
2026-05-18 21:04:12 +00:00
|
|
|
# ---------------------------------------------------------------------------
|
2026-05-18 15:13:06 +00:00
|
|
|
|
2026-05-18 21:55:27 +00:00
|
|
|
def _build_subject(correlation: dict) -> str:
|
|
|
|
|
"""Build a concise email subject line from the correlation outcome.
|
2026-05-18 21:04:12 +00:00
|
|
|
|
|
|
|
|
Args:
|
2026-05-18 21:55:27 +00:00
|
|
|
correlation: Output dict from ``correlate_backup_status()``.
|
2026-05-18 21:04:12 +00:00
|
|
|
|
|
|
|
|
Returns:
|
2026-05-18 21:55:27 +00:00
|
|
|
Subject string starting with ``[ns8-backup]``.
|
2026-05-18 20:29:55 +00:00
|
|
|
"""
|
2026-05-18 21:55:27 +00:00
|
|
|
outcome = correlation["outcome"]
|
|
|
|
|
total = correlation["total"]
|
|
|
|
|
failed = correlation["failed"]
|
|
|
|
|
succeeded = correlation["succeeded"]
|
|
|
|
|
|
|
|
|
|
if outcome == "SUCCESS":
|
|
|
|
|
return f"[ns8-backup] SUCCESS - all {total} module(s) backed up successfully"
|
|
|
|
|
elif outcome == "PARTIAL":
|
|
|
|
|
return f"[ns8-backup] PARTIAL - {failed}/{total} module(s) failed"
|
|
|
|
|
else:
|
|
|
|
|
note = correlation.get("note", "")
|
|
|
|
|
return f"[ns8-backup] REPO_FAILURE - {note or 'possible repository issue'}"
|
2026-05-18 20:06:01 +00:00
|
|
|
|
|
|
|
|
|
2026-05-18 21:04:12 +00:00
|
|
|
# ---------------------------------------------------------------------------
|
2026-05-18 21:55:27 +00:00
|
|
|
# Body builder
|
2026-05-18 21:04:12 +00:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-05-18 21:55:27 +00:00
|
|
|
def _build_body(
|
|
|
|
|
alerts: list,
|
|
|
|
|
correlation: dict,
|
|
|
|
|
repo_status: Optional[dict],
|
|
|
|
|
) -> str:
|
|
|
|
|
"""Build the plain-text email body.
|
2026-05-18 21:04:12 +00:00
|
|
|
|
|
|
|
|
Args:
|
2026-05-18 21:55:27 +00:00
|
|
|
alerts: Raw Alertmanager alert list from the webhook payload.
|
|
|
|
|
correlation: Output dict from ``correlate_backup_status()``.
|
|
|
|
|
repo_status: Output dict from ``check_repositories()``, or None if
|
|
|
|
|
the repo check was skipped (i.e. outcome == SUCCESS).
|
2026-05-18 21:04:12 +00:00
|
|
|
|
|
|
|
|
Returns:
|
2026-05-18 21:55:27 +00:00
|
|
|
Multi-line string suitable for direct use as the email body.
|
2026-05-18 21:04:12 +00:00
|
|
|
"""
|
2026-05-18 21:55:27 +00:00
|
|
|
outcome = correlation["outcome"]
|
|
|
|
|
backup_ids = correlation.get("backup_ids", [])
|
|
|
|
|
modules = correlation.get("modules", [])
|
|
|
|
|
failed_mods = correlation.get("failed_modules", [])
|
|
|
|
|
note = correlation.get("note", "")
|
|
|
|
|
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
|
|
|
|
|
|
|
|
lines = []
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Section 1: SUMMARY
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
lines.append("=" * 60)
|
|
|
|
|
lines.append("NS8 BACKUP MONITOR - STATUS REPORT")
|
|
|
|
|
lines.append("=" * 60)
|
|
|
|
|
lines.append(f"Timestamp : {now}")
|
|
|
|
|
lines.append(f"Outcome : {outcome}")
|
|
|
|
|
lines.append(f"Plan IDs : {', '.join(backup_ids) if backup_ids else 'unknown'}")
|
|
|
|
|
lines.append(f"Total : {correlation['total']} module(s)")
|
|
|
|
|
lines.append(f"Succeeded : {correlation['succeeded']}")
|
|
|
|
|
lines.append(f"Failed : {correlation['failed']}")
|
|
|
|
|
if note:
|
|
|
|
|
lines.append(f"Note : {note}")
|
|
|
|
|
lines.append("")
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Section 2: MODULE STATUS TABLE
|
|
|
|
|
# Shown on PARTIAL and REPO_FAILURE to list which modules failed.
|
|
|
|
|
# Omitted on SUCCESS to keep the email concise.
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
if outcome != "SUCCESS" and modules:
|
|
|
|
|
lines.append("-" * 60)
|
|
|
|
|
lines.append("MODULE STATUS")
|
|
|
|
|
lines.append("-" * 60)
|
|
|
|
|
# Fixed-width columns for plain-text readability.
|
|
|
|
|
header = f"{'Module':<20} {'Plan':>4} {'Result':<10} Error"
|
|
|
|
|
lines.append(header)
|
|
|
|
|
lines.append("-" * 60)
|
|
|
|
|
for m in modules:
|
|
|
|
|
result_str = m["result"].upper()
|
|
|
|
|
error_str = m["error"][:60] if m["error"] else "-"
|
|
|
|
|
lines.append(
|
|
|
|
|
f"{m['module_id']:<20} {m['backup_id']:>4} {result_str:<10} {error_str}"
|
|
|
|
|
)
|
2026-05-18 15:13:06 +00:00
|
|
|
lines.append("")
|
2026-05-18 21:04:12 +00:00
|
|
|
|
2026-05-18 21:55:27 +00:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Section 3: REPOSITORY DIAGNOSTICS
|
|
|
|
|
# Shown only when the repo check was run (non-SUCCESS outcomes).
|
|
|
|
|
# The repo check is skipped on SUCCESS to avoid unnecessary restic
|
|
|
|
|
# network calls, so repo_status is None in that case.
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-05-18 15:13:06 +00:00
|
|
|
if repo_status:
|
2026-05-18 21:55:27 +00:00
|
|
|
lines.append("-" * 60)
|
|
|
|
|
lines.append("REPOSITORY DIAGNOSTICS")
|
|
|
|
|
lines.append("-" * 60)
|
|
|
|
|
lines.append(f"Summary: {repo_status['summary']}")
|
|
|
|
|
lines.append("")
|
2026-05-18 15:13:06 +00:00
|
|
|
for dest in repo_status.get("destinations", []):
|
2026-05-18 21:55:27 +00:00
|
|
|
lines.append(f" Repo {dest['repo_id']}: {dest['status']}")
|
|
|
|
|
if dest.get("error"):
|
|
|
|
|
# Indent error detail under the repo line.
|
|
|
|
|
for err_line in dest["error"].splitlines()[:3]:
|
|
|
|
|
lines.append(f" {err_line}")
|
2026-05-18 15:13:06 +00:00
|
|
|
lines.append("")
|
2026-05-18 21:04:12 +00:00
|
|
|
|
2026-05-18 21:55:27 +00:00
|
|
|
lines.append("-" * 60)
|
|
|
|
|
lines.append("Sent by ns8-backup-monitor")
|
|
|
|
|
lines.append("https://github.com/lelekaos/ns8-backup-monitor")
|
|
|
|
|
lines.append("-" * 60)
|
2026-05-18 21:04:12 +00:00
|
|
|
|
2026-05-18 15:13:06 +00:00
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
2026-05-18 21:04:12 +00:00
|
|
|
# ---------------------------------------------------------------------------
|
2026-05-18 21:55:27 +00:00
|
|
|
# Delivery
|
2026-05-18 21:04:12 +00:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-05-18 21:55:27 +00:00
|
|
|
def _send_via_ns8_sendmail(
|
|
|
|
|
config: dict,
|
|
|
|
|
subject: str,
|
|
|
|
|
body: str,
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""Deliver the email through ``ns8-sendmail``.
|
2026-05-18 21:04:12 +00:00
|
|
|
|
2026-05-18 21:55:27 +00:00
|
|
|
``ns8-sendmail`` reads SMTP relay settings from the NS8 cluster
|
|
|
|
|
configuration, so no SMTP credentials are needed here.
|
2026-05-18 21:04:12 +00:00
|
|
|
|
|
|
|
|
Args:
|
2026-05-18 21:55:27 +00:00
|
|
|
config: Parsed configuration dictionary. Reads:
|
|
|
|
|
``notification.mail_to`` - recipient address or list.
|
|
|
|
|
``notification.mail_from`` - sender address.
|
|
|
|
|
subject: Email subject string.
|
|
|
|
|
body: Plain-text email body.
|
2026-05-18 21:04:12 +00:00
|
|
|
|
|
|
|
|
Returns:
|
2026-05-18 21:55:27 +00:00
|
|
|
True if ``ns8-sendmail`` exited with code 0, False otherwise.
|
2026-05-18 21:04:12 +00:00
|
|
|
"""
|
2026-05-18 21:55:27 +00:00
|
|
|
mail_to = config.get("notification", {}).get("mail_to", "")
|
|
|
|
|
mail_from = config.get("notification", {}).get(
|
|
|
|
|
"mail_from", "ns8-backup-monitor@localhost"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ``mail_to`` may be a string or a list in the YAML config.
|
|
|
|
|
if isinstance(mail_to, list):
|
|
|
|
|
recipients = mail_to
|
|
|
|
|
else:
|
|
|
|
|
# Split on comma for inline multi-recipient strings.
|
|
|
|
|
recipients = [r.strip() for r in mail_to.split(",") if r.strip()]
|
|
|
|
|
|
|
|
|
|
if not recipients:
|
|
|
|
|
log.error("No mail_to recipients configured; skipping notification")
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
success = True
|
|
|
|
|
for recipient in recipients:
|
|
|
|
|
try:
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
["ns8-sendmail", "--from", mail_from, "--to", recipient,
|
|
|
|
|
"--subject", subject],
|
|
|
|
|
input=body,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=30,
|
2026-05-18 21:04:12 +00:00
|
|
|
)
|
2026-05-18 21:55:27 +00:00
|
|
|
if result.returncode == 0:
|
|
|
|
|
log.info("Notification sent to %s", recipient)
|
|
|
|
|
else:
|
|
|
|
|
log.error(
|
|
|
|
|
"ns8-sendmail failed for %s (exit %d): %s",
|
|
|
|
|
recipient, result.returncode, result.stderr.strip()
|
|
|
|
|
)
|
|
|
|
|
success = False
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
log.error(
|
|
|
|
|
"ns8-sendmail not found in PATH - "
|
|
|
|
|
"ensure the NS8 mail module is installed on the leader"
|
2026-05-18 21:04:12 +00:00
|
|
|
)
|
2026-05-18 21:55:27 +00:00
|
|
|
success = False
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
log.error("ns8-sendmail timed out for %s", recipient)
|
|
|
|
|
success = False
|
2026-05-18 21:04:12 +00:00
|
|
|
|
2026-05-18 21:55:27 +00:00
|
|
|
return success
|
2026-05-18 21:04:12 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Main entry point
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def send_notification(
|
|
|
|
|
config: dict,
|
|
|
|
|
alerts: list,
|
|
|
|
|
correlation: dict,
|
2026-05-18 21:55:27 +00:00
|
|
|
repo_status: Optional[dict],
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""Build and send the backup status notification email.
|
2026-05-18 21:04:12 +00:00
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
config: Parsed configuration dictionary.
|
2026-05-18 21:55:27 +00:00
|
|
|
alerts: Raw Alertmanager alert list from the webhook payload.
|
|
|
|
|
correlation: Output dict from ``correlate_backup_status()``.
|
|
|
|
|
repo_status: Output dict from ``check_repositories()``, or None
|
|
|
|
|
when the outcome is SUCCESS (repo check skipped).
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
True if the email was delivered successfully, False otherwise.
|
2026-05-18 21:04:12 +00:00
|
|
|
"""
|
2026-05-18 21:55:27 +00:00
|
|
|
subject = _build_subject(correlation)
|
|
|
|
|
body = _build_body(alerts, correlation, repo_status)
|
2026-05-18 15:13:06 +00:00
|
|
|
|
2026-05-18 21:55:27 +00:00
|
|
|
log.info("Sending notification: %s", subject)
|
|
|
|
|
return _send_via_ns8_sendmail(config, subject, body)
|