— Implement mail alerts. Fix #15

This commit is contained in:
Luc Didry 2023-11-23 17:06:56 +01:00
parent bb39e53845
commit de1f0cc22a
No known key found for this signature in database
GPG key ID: EA868E12D0257E3C
4 changed files with 96 additions and 4 deletions

View file

@ -4,7 +4,7 @@ For database models, see argos.server.models.
""" """
from typing import Dict, List, Literal, Optional, Tuple 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 pydantic.functional_validators import BeforeValidator
from typing_extensions import Annotated from typing_extensions import Annotated
@ -93,6 +93,23 @@ class Service(BaseModel):
secrets: List[str] 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): class Alert(BaseModel):
"""List of way to handle alerts, by severity""" """List of way to handle alerts, by severity"""
@ -107,6 +124,7 @@ class General(BaseModel):
frequency: int frequency: int
alerts: Alert alerts: Alert
mail: Optional[Mail] = None
@field_validator("frequency", mode="before") @field_validator("frequency", mode="before")
def parse_frequency(cls, value): def parse_frequency(cls, value):

View file

@ -1,3 +1,8 @@
import ssl
import smtplib
from urllib.parse import urlparse
from argos.logging import logger from argos.logging import logger
# XXX Implement mail alerts https://framagit.org/framasoft/framaspace/argos/-/issues/15 # 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""" """Dispatch alert through configured alert channels"""
msg = f"task={task.id}, status={result.status}, {severity=}" msg = f"task={task.id}, status={result.status}, {severity=}"
logger.error("Alerting stub: %s", msg) 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

View file

@ -44,19 +44,22 @@ async def create_results(
agent_id = agent_id or request.client.host agent_id = agent_id or request.client.host
db_results = [] db_results = []
for agent_result in results: for agent_result in results:
result = await queries.create_result(db, agent_result, agent_id)
# XXX Maybe offload this to a queue. # XXX Maybe offload this to a queue.
# XXX Get all the tasks at once, to limit the queries on the db # XXX Get all the tasks at once, to limit the queries on the db
task = await queries.get_task(db, agent_result.task_id) task = await queries.get_task(db, agent_result.task_id)
if not task: if not task:
logger.error("Unable to find task %i", agent_result.task_id) logger.error("Unable to find task %i", agent_result.task_id)
else: else:
last_severity = task.severity
result = await queries.create_result(db, agent_result, agent_id)
check = task.get_check() check = task.get_check()
status, severity = await check.finalize(config, result, **result.context) status, severity = await check.finalize(config, result, **result.context)
result.set_status(status, severity) result.set_status(status, severity)
task.set_times_and_deselect() task.set_times_and_deselect()
handle_alert(config, result, task, severity) # Dont create an alert if the severity has not changed
if last_severity != severity:
handle_alert(config, result, task, severity)
db_results.append(result) db_results.append(result)
db.commit() db.commit()

View file

@ -20,7 +20,7 @@ dependencies = [
"click>=8.1,<9", "click>=8.1,<9",
"fastapi>=0.103,<0.104", "fastapi>=0.103,<0.104",
"httpx>=0.25,<1", "httpx>=0.25,<1",
"pydantic>=2.4,<3", "pydantic[email]>=2.4,<3",
"pyyaml>=6.0,<7", "pyyaml>=6.0,<7",
"pyyaml-include>=1.3,<2", "pyyaml-include>=1.3,<2",
"sqlalchemy[asyncio]>=2.0,<3", "sqlalchemy[asyncio]>=2.0,<3",