diff --git a/.pylintrc b/.pylintrc
index 041c69d..c5b3cdd 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -441,7 +441,9 @@ disable=raw-checker-failed,
singleton-comparison,
missing-module-docstring,
missing-class-docstring,
- missing-function-docstring
+ missing-function-docstring,
+ too-many-arguments,
+ too-many-locals,
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
diff --git a/README.md b/README.md
index 73464d3..6e48daa 100644
--- a/README.md
+++ b/README.md
@@ -22,13 +22,13 @@ Internally, a HTTP API is exposed, and a job queue is used to distribute the che
- [X] donner un aperçu rapide de l’état de la supervision.
- [ ] Use background tasks for alerting (#23)
- [ ] Delete outdated tasks from config (#19, !25)
-- [ ] Implement alerting tasks (#15, 16, !13)
-- [ ] Handles multiple alerting backends (email, sms, gotify) (!13)
+- [X] Implement alerting tasks (#15, 16, !13)
+- [X] Handles multiple alerting backends (email, sms, gotify) (!13)
- [ ] add an "unknown" severity for check errors (!17)
- [ ] Add a command to generate new authentication token (#22)
- [ ] Add a way to specify the severity of the alerts in the config
- [ ] Allow passing a dict to check
-- [ ] Un flag de configuration permet d’ajouter automatiquement un job de vérification de redirection 301 de la version HTTP vers HTTPS
+- [ ] A configuration flag can automatically add a check of 301 redirection from HTTP to HTTPS
## License
diff --git a/argos/schemas/config.py b/argos/schemas/config.py
index e7a7825..160b5ae 100644
--- a/argos/schemas/config.py
+++ b/argos/schemas/config.py
@@ -4,7 +4,15 @@ 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 +101,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: str = '127.0.0.1'
+ port: PositiveInt = 25
+ ssl: StrictBool = False
+ starttls: StrictBool = False
+ auth: Optional[MailAuth] = None
+ addresses: List[EmailStr]
+
+
class Alert(BaseModel):
"""List of way to handle alerts, by severity"""
@@ -102,11 +127,18 @@ class Alert(BaseModel):
unknown: List[str]
+class GotifyUrl(BaseModel):
+ url: HttpUrl
+ tokens: List[str]
+
+
class General(BaseModel):
"""Frequency for the checks and alerts"""
frequency: int
alerts: Alert
+ mail: Optional[Mail] = None
+ gotify: Optional[List[GotifyUrl]] = 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..7c07533 100644
--- a/argos/server/alerting.py
+++ b/argos/server/alerting.py
@@ -1,9 +1,125 @@
+import ssl
+import smtplib
+
+from typing import List
+from urllib.parse import urlparse
+
+import httpx
+
+from argos.checks.base import Severity
from argos.logging import logger
+from argos.schemas.config import Config, Mail, GotifyUrl
# XXX Implement mail alerts https://framagit.org/framasoft/framaspace/argos/-/issues/15
# XXX Implement gotify alerts https://framagit.org/framasoft/framaspace/argos/-/issues/16
-def handle_alert(config, result, task, severity):
+def handle_alert(config: Config, result, task, severity, old_severity, request):
"""Dispatch alert through configured alert channels"""
- msg = f"task={task.id}, status={result.status}, {severity=}"
- logger.error("Alerting stub: %s", msg)
+
+ if 'local' in getattr(config.general.alerts, severity):
+ logger.error("Alerting stub: task=%i, status=%s, severity=%s",
+ task.id,
+ result.status,
+ severity)
+
+ if config.general.mail is not None and \
+ 'mail' in getattr(config.general.alerts, severity):
+ notify_by_mail(result, task, severity, old_severity, config.general.mail, request)
+
+ if config.general.gotify is not None and \
+ 'gotify' in getattr(config.general.alerts, severity):
+ notify_with_gotify(result, task, severity, old_severity, config.general.gotify, request)
+
+
+def notify_by_mail(result, task, severity: str, old_severity: str, config: Mail, request) -> None:
+ logger.debug('Will send mail notification')
+
+ msg = f"""\
+URL: {task.url}
+Check: {task.check}
+Status: {severity}
+Time: {result.submitted_at}
+Previous status: {old_severity}
+
+See results of task on {request.url_for('get_task_results', task_id=task.id)}
+"""
+
+ mail = f"""\
+Subject: [Argos] {urlparse(task.url).netloc}: status {severity}
+
+{msg}"""
+
+ 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 config.auth is not 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.mailfrom, address, mail)
+
+
+def notify_with_gotify(
+ result,
+ task,
+ severity: str,
+ old_severity: str,
+ config: List[GotifyUrl],
+ request
+) -> None:
+ logger.debug('Will send gotify notification')
+ headers = {'accept': 'application/json',
+ 'content-type': 'application/json'}
+
+ priority = 9
+ icon = '❌'
+ if severity == Severity.OK:
+ priority = 1
+ icon = '✅'
+ elif severity == Severity.WARNING:
+ priority = 5
+ icon = '⚠️'
+
+ subject = f"{icon} {urlparse(task.url).netloc}: status {severity}"
+ msg = f"""\
+URL: {task.url}
+Check: {task.check}
+Status: {severity}
+Time: {result.submitted_at}
+Previous status: {old_severity}
+
+See results of task on {request.url_for('get_task_results', task_id=task.id)}
+"""
+
+ payload = {'title': subject,
+ 'message': msg,
+ 'priority': priority}
+
+ for url in config:
+ logger.debug('Sending gotify message(s) to %s', url)
+ for token in url.tokens:
+ try:
+ res = httpx.post(f"{url.url}message",
+ params={'token': token},
+ headers=headers,
+ json=payload)
+ res.raise_for_status()
+ except httpx.RequestError as err:
+ logger.error('An error occurred while sending a message to %s with token %s',
+ err.request.url,
+ token)
diff --git a/argos/server/routes/api.py b/argos/server/routes/api.py
index 57001c6..244f499 100644
--- a/argos/server/routes/api.py
+++ b/argos/server/routes/api.py
@@ -44,19 +44,23 @@ 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:
+ # XXX Use a job queue or make it async
+ handle_alert(config, result, task, severity, last_severity, request)
db_results.append(result)
db.commit()
diff --git a/argos/server/templates/index.html b/argos/server/templates/index.html
index 9d347ef..e1b41bf 100644
--- a/argos/server/templates/index.html
+++ b/argos/server/templates/index.html
@@ -12,7 +12,7 @@
{{ counts_dict['ok'] }}
-
+
{{ counts_dict['warning'] }}
diff --git a/config-example.yaml b/config-example.yaml
index d2afa2a..8bbf908 100644
--- a/config-example.yaml
+++ b/config-example.yaml
@@ -10,6 +10,24 @@ general:
- local
unknown:
- local
+# mail:
+# mailfrom: no-reply@example.org
+# host: 127.0.0.1
+# port: 25
+# ssl: False
+# starttls: False
+# auth:
+# login: foo
+# password: bar
+# addresses:
+# - foo@admin.example.org
+# - bar@admin.example.org
+# gotify:
+# - url: https://example.org
+# tokens:
+# - foo
+# - bar
+
service:
secrets:
# Secrets can be generated using `openssl rand -base64 32`.
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",