From de1f0cc22aa016a0dd5b2f39761314d94cb4a406 Mon Sep 17 00:00:00 2001 From: Luc Didry Date: Thu, 23 Nov 2023 17:06:56 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20=E2=80=94=20Implement=20mail=20aler?= =?UTF-8?q?ts.=20Fix=20#15?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- argos/schemas/config.py | 20 ++++++++++- argos/server/alerting.py | 71 ++++++++++++++++++++++++++++++++++++++ argos/server/routes/api.py | 7 ++-- pyproject.toml | 2 +- 4 files changed, 96 insertions(+), 4 deletions(-) diff --git a/argos/schemas/config.py b/argos/schemas/config.py index e7a7825..890ff2d 100644 --- a/argos/schemas/config.py +++ b/argos/schemas/config.py @@ -4,7 +4,7 @@ For database models, see argos.server.models. """ from typing import Dict, List, Literal, Optional, Tuple -from pydantic import BaseModel, ConfigDict, HttpUrl, field_validator +from pydantic import BaseModel, ConfigDict, HttpUrl, StrictBool, EmailStr, PositiveInt, field_validator from pydantic.functional_validators import BeforeValidator from typing_extensions import Annotated @@ -93,6 +93,23 @@ class Service(BaseModel): secrets: List[str] +class MailAuth(BaseModel): + """Mail authentication configuration""" + login: str + password: str + + +class Mail(BaseModel): + """Mail configuration""" + mailfrom: EmailStr + host: Optional[str] = None + port: Optional[PositiveInt] = None + ssl: Optional[StrictBool] = None + starttls: Optional[StrictBool] = None + auth: Optional[MailAuth] = None + addresses: List[EmailStr] + + class Alert(BaseModel): """List of way to handle alerts, by severity""" @@ -107,6 +124,7 @@ class General(BaseModel): frequency: int alerts: Alert + mail: Optional[Mail] = None @field_validator("frequency", mode="before") def parse_frequency(cls, value): diff --git a/argos/server/alerting.py b/argos/server/alerting.py index 5f5821a..7fc071b 100644 --- a/argos/server/alerting.py +++ b/argos/server/alerting.py @@ -1,3 +1,8 @@ +import ssl +import smtplib + +from urllib.parse import urlparse + from argos.logging import logger # XXX Implement mail alerts https://framagit.org/framasoft/framaspace/argos/-/issues/15 @@ -7,3 +12,69 @@ def handle_alert(config, result, task, severity): """Dispatch alert through configured alert channels""" msg = f"task={task.id}, status={result.status}, {severity=}" logger.error("Alerting stub: %s", msg) + + if 'mail' in config and 'addresses' in config['mail']: + logger.debug('Notify of failure by mail') + + msg = f"""\ +Subject: Argos {urlparse(task.url).netloc} status {severity} + +URL: {task.url} +Check: {task.check} +Status: {severity} +Time: {result.submitted_at} +""" + notify_by_mail(msg=msg, config=config['mail']) + + +def notify_by_mail(msg: str, config: dict) -> None: + """Notify by mail + + Keyword argument: + msg -- string, the mail to send (including subject) + config -- dict, configuration of mail system + """ + if 'mailfrom' not in config: + logger.error('No "mailfrom" address in mail config. ' + 'Not sending mail.') + return None + if 'host' not in config: + config['host'] = '127.0.0.1' + if 'port' not in config: + config['port'] = 25 + if 'ssl' not in config: + config['ssl'] = False + if 'starttls' not in config: + config['starttls'] = False + if config['ssl']: + logger.debug('Mail notification: SSL') + context = ssl.create_default_context() + smtp = smtplib.SMTP_SSL(host=config['host'], + port=config['port'], + context=context) + else: + smtp = smtplib.SMTP(host=config['host'], # type: ignore + port=config['port']) + if config['starttls']: + logger.debug('Mail notification: STARTTLS') + context = ssl.create_default_context() + smtp.starttls(context=context) + + if 'auth' in config: + if 'login' not in config['auth'] \ + or 'password' not in config['auth']: + logger.warning('Mail credentials are incomplete. ' + 'No mail authentication can be done. ' + 'Not sending mail.') + return None + + logger.debug('Mail notification: authentification') + smtp.login(config['auth']['login'], + config['auth']['password']) + + for address in config['addresses']: + logger.debug('Sending mail to %s', address) + logger.debug(msg) + smtp.sendmail(config['from'], address, msg) + + return None diff --git a/argos/server/routes/api.py b/argos/server/routes/api.py index 57001c6..034bc19 100644 --- a/argos/server/routes/api.py +++ b/argos/server/routes/api.py @@ -44,19 +44,22 @@ async def create_results( agent_id = agent_id or request.client.host db_results = [] for agent_result in results: - result = await queries.create_result(db, agent_result, agent_id) # XXX Maybe offload this to a queue. # XXX Get all the tasks at once, to limit the queries on the db task = await queries.get_task(db, agent_result.task_id) if not task: logger.error("Unable to find task %i", agent_result.task_id) else: + last_severity = task.severity + result = await queries.create_result(db, agent_result, agent_id) check = task.get_check() status, severity = await check.finalize(config, result, **result.context) result.set_status(status, severity) task.set_times_and_deselect() - handle_alert(config, result, task, severity) + # Don’t create an alert if the severity has not changed + if last_severity != severity: + handle_alert(config, result, task, severity) db_results.append(result) db.commit() diff --git a/pyproject.toml b/pyproject.toml index f4a47ff..67da05e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "click>=8.1,<9", "fastapi>=0.103,<0.104", "httpx>=0.25,<1", - "pydantic>=2.4,<3", + "pydantic[email]>=2.4,<3", "pyyaml>=6.0,<7", "pyyaml-include>=1.3,<2", "sqlalchemy[asyncio]>=2.0,<3",