From cab17e72537b4188a2df4e2bedc6afa92d4f8810 Mon Sep 17 00:00:00 2001 From: David Douard <david.douard@sdfa3.org> Date: Fri, 5 Aug 2022 19:16:21 +0200 Subject: [PATCH] Instanciate the SMTP class only when needed instead of creating it in the VaultBackend constructor: when configured (with host and port), SMTP.connect() is immediately called, which makes it mandatory to have the smtp server up and running to be able to create the VaultBackend object (which makes it hard to run properly in an elastic environment like docker or k8s). This also removes the fallback to hardcoded 'localhost:25' smtp server; if the smtp server is not configured or not reachable, the call to _smtp_send() will fail logging the failure (both in logging and sentry). --- swh/vault/backend.py | 29 ++++++++++---- swh/vault/tests/test_backend.py | 69 +++++++++++++++++++++++++++++++-- 2 files changed, 87 insertions(+), 11 deletions(-) diff --git a/swh/vault/backend.py b/swh/vault/backend.py index b7d9bf3..bc51160 100644 --- a/swh/vault/backend.py +++ b/swh/vault/backend.py @@ -5,11 +5,13 @@ import collections from email.mime.text import MIMEText +import logging import smtplib from typing import Any, Dict, List, Optional, Tuple import psycopg2.extras import psycopg2.pool +import sentry_sdk from swh.core.db import BaseDb from swh.core.db.common import db_transaction @@ -21,6 +23,7 @@ from swh.vault.cache import VaultCache from swh.vault.cookers import COOKER_TYPES, get_cooker_cls from swh.vault.exc import NotFoundExc +logger = logging.getLogger(__name__) cooking_task_name = "swh.vault.cooking_tasks.SWHCookingTask" NOTIF_EMAIL_FROM = '"Software Heritage Vault" ' "<bot@softwareheritage.org>" @@ -75,7 +78,6 @@ class VaultBackend: self.cache = VaultCache(**config["cache"]) self.scheduler = get_scheduler(**config["scheduler"]) self.storage = get_storage(**config["storage"]) - self.smtp_server = smtplib.SMTP(**config.get("smtp", {})) if "db" not in self.config: raise ValueError( @@ -487,16 +489,29 @@ class VaultBackend: ) def _smtp_send(self, msg: MIMEText): - # Reconnect if needed + smtp_server = smtplib.SMTP(**self.config.get("smtp", {})) try: - status = self.smtp_server.noop()[0] + status = smtp_server.noop()[0] except smtplib.SMTPException: status = -1 if status != 250: - self.smtp_server.connect("localhost", 25) - - # Send the message - self.smtp_server.send_message(msg) + error_message = ( + f"Unable to send SMTP message '{msg['Subject']}' to " + f"{msg['To']}: cannot connect to server" + ) + logger.error(error_message) + sentry_sdk.capture_message(error_message, "error") + else: + try: + # Send the message + smtp_server.send_message(msg) + except smtplib.SMTPException as exc: + logger.exception(exc) + error_message = ( + f"Unable to send SMTP message '{msg['Subject']}' to " + f"{msg['To']}: {exc}" + ) + sentry_sdk.capture_message(error_message, "error") @db_transaction() def _cache_expire(self, cond, *args, db=None, cur=None) -> None: diff --git a/swh/vault/tests/test_backend.py b/swh/vault/tests/test_backend.py index 98c5025..b06b435 100644 --- a/swh/vault/tests/test_backend.py +++ b/swh/vault/tests/test_backend.py @@ -5,12 +5,15 @@ import contextlib import datetime +import re +import smtplib from unittest.mock import MagicMock, patch import attr import psycopg2 import pytest +from swh.core.sentry import init_sentry from swh.model.model import Content from swh.model.swhids import CoreSWHID from swh.vault.exc import NotFoundExc @@ -212,10 +215,10 @@ def test_send_all_emails(swh_vault): swh_vault.set_status(TEST_TYPE, TEST_SWHID, "done") - with patch.object(swh_vault, "smtp_server") as m: + with patch.object(swh_vault, "_smtp_send") as m: swh_vault.send_notif(TEST_TYPE, TEST_SWHID) - sent_emails = {k[0][0] for k in m.send_message.call_args_list} + sent_emails = {k[0][0] for k in m.call_args_list} assert {k["To"] for k in sent_emails} == set(emails) for e in sent_emails: @@ -234,6 +237,64 @@ def test_send_all_emails(swh_vault): m.assert_not_called() +def test_send_email_error_no_smtp(swh_vault): + reports = [] + init_sentry("http://example.org", extra_kwargs={"transport": reports.append}) + + emails = ("a@example.com", "billg@example.com", "test+42@example.org") + with mock_cooking(swh_vault): + for email in emails: + swh_vault.cook(TEST_TYPE, TEST_SWHID, email=email) + swh_vault.set_status(TEST_TYPE, TEST_SWHID, "done") + swh_vault.send_notif(TEST_TYPE, TEST_SWHID) + + assert len(reports) == 6 + for i, email in enumerate(emails): + # first report is the logger.error + assert reports[2 * i]["level"] == "error" + assert reports[2 * i]["logger"] == "swh.vault.backend" + reg = re.compile( + "Unable to send SMTP message 'Bundle ready: gitfast [0-9a-f]{7}' " + f"to {email.replace('+', '[+]')}: cannot connect to server" + ) + assert reg.match(reports[2 * i]["logentry"]["message"]) + # second is the sentry_sdk.capture_message + assert reports[2 * i + 1]["level"] == "error" + assert reg.match(reports[2 * i + 1]["message"]) + + +def test_send_email_error_send_failed(swh_vault): + reports = [] + init_sentry("http://example.org", extra_kwargs={"transport": reports.append}) + + emails = ("a@example.com", "billg@example.com", "test+42@example.org") + with mock_cooking(swh_vault): + for email in emails: + swh_vault.cook(TEST_TYPE, TEST_SWHID, email=email) + swh_vault.set_status(TEST_TYPE, TEST_SWHID, "done") + + with patch("smtplib.SMTP") as MockSMTP: + smtp = MockSMTP.return_value + smtp.noop.return_value = [250] + smtp.send_message.side_effect = smtplib.SMTPHeloError(404, "HELO Failed") + + swh_vault.send_notif(TEST_TYPE, TEST_SWHID) + + assert len(reports) == 4 + # first one is the captured exception + assert reports[0]["level"] == "error" + assert reports[0]["exception"]["values"][0]["type"] == "SMTPHeloError" + + # the following 3 ones are the sentry_sdk.capture_message() calls + for i, email in enumerate(emails, start=1): + assert reports[i]["level"] == "error" + reg = re.compile( + "Unable to send SMTP message 'Bundle ready: gitfast [0-9a-f]{7}' " + f"to {email.replace('+', '[+]')}: [(]404, 'HELO Failed'[)]" + ) + assert reg.match(reports[i]["message"]) + + def test_available(swh_vault): assert not swh_vault.is_available(TEST_TYPE, TEST_SWHID) @@ -327,10 +388,10 @@ def test_send_failure_email(swh_vault): swh_vault.set_status(TEST_TYPE, TEST_SWHID, "failed") swh_vault.set_progress(TEST_TYPE, TEST_SWHID, "test error") - with patch.object(swh_vault, "smtp_server") as m: + with patch.object(swh_vault, "_smtp_send") as m: swh_vault.send_notif(TEST_TYPE, TEST_SWHID) - e = [k[0][0] for k in m.send_message.call_args_list][0] + e = [k[0][0] for k in m.call_args_list][0] assert e["To"] == "a@example.com" assert "bot@softwareheritage.org" in e["From"] -- GitLab