diff --git a/ns8_backup_monitor/notifier.py b/ns8_backup_monitor/notifier.py index 994210f..60cd235 100644 --- a/ns8_backup_monitor/notifier.py +++ b/ns8_backup_monitor/notifier.py @@ -1,28 +1,36 @@ #!/usr/bin/env python3 -"""Build and send backup outcome email notifications via ns8-sendmail. +"""Compose and send the backup status notification email. -This module is the final stage of the pipeline. It takes the correlation -result and the optional repository health check result, renders both a -plain-text and an HTML email body, and dispatches the message through the -NS8 mail relay using ``runagent ns8-sendmail``. +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 / runagent? ----------------------------- -NS8 modules are containerised; the cluster mail relay is exposed through -the ``runagent`` helper which bridges the host and the container network. -``ns8-sendmail`` reads the relay configuration from the NS8 cluster state, -so no SMTP settings need to be stored in this project's config file. +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. -Correct invocation (verified against NS8 source): - runagent ns8-sendmail -s [-f ] [ ...] - Body is read from stdin as plain text. - ns8-sendmail does NOT parse To:/From:/Subject: headers from the body. +Email structure +--------------- +The email is plain text with three sections: -Outcome labels and colours used in the HTML email --------------------------------------------------- - SUCCESS – label "OK", header background #2e7d32 (green) - PARTIAL – label "WARNING", header background #e65100 (orange) - REPO_FAILURE – label "CRITICAL", header background #b71c1c (red) + 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) """ import logging @@ -32,207 +40,199 @@ from typing import Optional log = logging.getLogger(__name__) -# --------------------------------------------------------------------------- -# Outcome presentation maps -# --------------------------------------------------------------------------- -# Maps the three internal outcome codes to a short label and a background -# colour used in the HTML email header banner. - -OUTCOME_LABEL = { - "SUCCESS": "OK", - "PARTIAL": "WARNING", - "REPO_FAILURE": "CRITICAL", -} - -OUTCOME_COLOR = { - "SUCCESS": "#2e7d32", # Material green 800 - "PARTIAL": "#e65100", # Material deep-orange 900 - "REPO_FAILURE": "#b71c1c", # Material red 900 -} - # --------------------------------------------------------------------------- -# Command builder +# Subject builder # --------------------------------------------------------------------------- -def _send_cmd(subject: str, mail_from: str, mail_to: list) -> list: - """Build the argv list for invoking ns8-sendmail via runagent. - - ``runagent`` must be in PATH, which is guaranteed for root on NS8 nodes. +def _build_subject(correlation: dict) -> str: + """Build a concise email subject line from the correlation outcome. Args: - subject: Email subject line (already includes prefix and date). - mail_from: Envelope From address (may be empty; ns8-sendmail has a default). - mail_to: List of recipient addresses. + correlation: Output dict from ``correlate_backup_status()``. Returns: - List of strings ready to pass to subprocess.run(). + Subject string starting with ``[ns8-backup]``. """ - cmd = ["runagent", "ns8-sendmail", "-s", subject] - if mail_from: - cmd += ["-f", mail_from] - cmd += mail_to - return cmd + 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'}" # --------------------------------------------------------------------------- -# Plain-text body renderer +# Body builder # --------------------------------------------------------------------------- -def _build_text(correlation: dict, repo_status: Optional[dict]) -> str: - """Render a plain-text email body from the correlation and repo results. - - Produces a human-readable report suitable for terminal mail clients and - as a fallback for email clients that do not render HTML. +def _build_body( + alerts: list, + correlation: dict, + repo_status: Optional[dict], +) -> str: + """Build the plain-text email body. Args: - correlation: Dict returned by correlator.correlate_backup_status(). - repo_status: Dict returned by repo_check.check_repositories(), or None. + 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). Returns: - Multi-line string (Unix line endings). + Multi-line string suitable for direct use as the email body. """ - outcome = correlation["outcome"] - lines = [ - f"NS8 Backup Monitor - {OUTCOME_LABEL[outcome]}: {outcome}", - f"Time: {datetime.now(timezone.utc).isoformat()}", - f"Plans: {', '.join(correlation.get('backup_ids', []))}", - f"Modules: {correlation['succeeded']} OK / " - f"{correlation['failed']} FAILED / {correlation['total']} total", - "", - ] + 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") - # List each failed module with its error message. - if correlation["failed_modules"]: - lines.append("Failed modules:") - for m in correlation["failed_modules"]: - lines.append(f" - [{m['module_id']}] {m['backup_id']}: {m.get('error', '?')}") - lines.append("") + lines = [] - # Append repository health check details when available. - if repo_status: - lines.append("Repository check:") - for dest in repo_status.get("destinations", []): + # ------------------------------------------------------------------ + # 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" - [{dest['repo_id']}] {dest['status']}: {dest.get('error', '')}" + f"{m['module_id']:<20} {m['backup_id']:>4} {result_str:<10} {error_str}" ) - if repo_status.get("note"): - lines.append(f" NOTE: {repo_status['note']}") lines.append("") - if correlation.get("note"): - lines.append(f"Note: {correlation['note']}") + # ------------------------------------------------------------------ + # 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. + # ------------------------------------------------------------------ + if repo_status: + lines.append("-" * 60) + lines.append("REPOSITORY DIAGNOSTICS") + lines.append("-" * 60) + lines.append(f"Summary: {repo_status['summary']}") + lines.append("") + for dest in repo_status.get("destinations", []): + 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}") + lines.append("") + + lines.append("-" * 60) + lines.append("Sent by ns8-backup-monitor") + lines.append("https://github.com/lelekaos/ns8-backup-monitor") + lines.append("-" * 60) return "\n".join(lines) # --------------------------------------------------------------------------- -# HTML body renderer +# Delivery # --------------------------------------------------------------------------- -def _build_html(correlation: dict, repo_status: Optional[dict]) -> str: - """Render an HTML email body from the correlation and repo results. +def _send_via_ns8_sendmail( + config: dict, + subject: str, + body: str, +) -> bool: + """Deliver the email through ``ns8-sendmail``. - Produces a self-contained HTML document with: - - A coloured header banner showing the outcome and timestamp. - - A summary line with module counts. - - A per-module status table with colour-coded rows (green/red). - - An optional repository check table appended when repo_status is present. - - Inline styles are used throughout to maximise compatibility with - webmail clients that strip