diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fe84eb..0518bd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +- 💄 — Improve email and gotify notifications +- ✨ — Add command to test gotify configuration +- ✨ — Add nagios command to use as a Nagios probe +- ✨ — Add Apprise as notification way (#50) + ## 0.3.1 Date: 2024-09-02 diff --git a/argos/commands.py b/argos/commands.py index 5b13764..ac1309f 100644 --- a/argos/commands.py +++ b/argos/commands.py @@ -607,12 +607,196 @@ async def test_mail(config, domain, severity): notify_by_mail( result, task, - severity="SEVERITY", + severity=severity, old_severity="OLD SEVERITY", config=conf.general.mail, request=_FalseRequest(), ) +@server.command() +@click.option( + "--config", + default="argos-config.yaml", + help="Path of the configuration file. " + "If ARGOS_YAML_FILE environment variable is set, its value will be used instead.", + envvar="ARGOS_YAML_FILE", + callback=validate_config_access, +) +@click.option("--domain", help="Domain for the notification", default="example.org") +@click.option("--severity", help="Severity", default="CRITICAL") +@coroutine +async def test_gotify(config, domain, severity): + """Send a test gotify notification""" + os.environ["ARGOS_YAML_FILE"] = config + + from datetime import datetime + + from argos.logging import set_log_level + from argos.server.alerting import notify_with_gotify + from argos.server.main import read_config + from argos.server.models import Result, Task + + conf = read_config(config) + + if not conf.general.gotify: + click.echo("Gotify notifications are not configured, cannot test", err=True) + sysexit(1) + else: + now = datetime.now() + task = Task( + url=f"https://{domain}", + domain=domain, + check="body-contains", + expected="foo", + frequency=1, + selected_by="test", + selected_at=now, + ) + + result = Result( + submitted_at=now, + status="success", + context={"foo": "bar"}, + task=task, + agent_id="test", + severity="ok", + ) + + class _FalseRequest: + def url_for(*args, **kwargs): + return "/url" + + set_log_level("debug") + notify_with_gotify( + result, + task, + severity=severity, + old_severity="OLD SEVERITY", + config=conf.general.gotify, + request=_FalseRequest(), + ) + + +@server.command() +@click.option( + "--config", + default="argos-config.yaml", + help="Path of the configuration file. " + "If ARGOS_YAML_FILE environment variable is set, its value will be used instead.", + envvar="ARGOS_YAML_FILE", + callback=validate_config_access, +) +@click.option("--domain", help="Domain for the notification", default="example.org") +@click.option("--severity", help="Severity", default="CRITICAL") +@click.option( + "--apprise-group", help="Apprise group for the notification", required=True +) +@coroutine +async def test_apprise(config, domain, severity, apprise_group): + """Send a test apprise notification""" + os.environ["ARGOS_YAML_FILE"] = config + + from datetime import datetime + + from argos.logging import set_log_level + from argos.server.alerting import notify_with_apprise + from argos.server.main import read_config + from argos.server.models import Result, Task + + conf = read_config(config) + + if not conf.general.apprise: + click.echo("Apprise notifications are not configured, cannot test", err=True) + sysexit(1) + else: + now = datetime.now() + task = Task( + url=f"https://{domain}", + domain=domain, + check="body-contains", + expected="foo", + frequency=1, + selected_by="test", + selected_at=now, + ) + + result = Result( + submitted_at=now, + status="success", + context={"foo": "bar"}, + task=task, + agent_id="test", + severity="ok", + ) + + class _FalseRequest: + def url_for(*args, **kwargs): + return "/url" + + set_log_level("debug") + notify_with_apprise( + result, + task, + severity=severity, + old_severity="OLD SEVERITY", + group=conf.general.apprise[apprise_group], + request=_FalseRequest(), + ) + + +@server.command(short_help="Nagios compatible severities report") +@click.option( + "--config", + default="argos-config.yaml", + help="Path of the configuration file. " + "If ARGOS_YAML_FILE environment variable is set, its value will be used instead.", + envvar="ARGOS_YAML_FILE", + callback=validate_config_access, +) +@coroutine +async def nagios(config): + """Output a report of current severities suitable for Nagios + with a Nagios compatible exit code""" + os.environ["ARGOS_YAML_FILE"] = config + + # The imports are made here otherwise the agent will need server configuration files. + from argos.server import queries + + exit_nb = 0 + db = await get_db() + severities = await queries.get_severity_counts(db) + + if severities["warning"] != 0: + exit_nb = 1 + if severities["critical"] != 0: + exit_nb = 2 + if severities["unknown"] != 0: + exit_nb = 2 + + stats = ( + f"ok={severities['ok']}; warning={severities['warning']}; " + f"critical={severities['critical']}; unknown={severities['unknown']};" + ) + + if exit_nb == 0: + print("OK — All sites are ok|{stats}") + elif exit_nb == 1: + print(f"WARNING — {severities['warning']} sites are in warning state|{stats}") + elif severities["critical"] == 0: + print(f"UNKNOWN — {severities['unknown']} sites are in unknown state|{stats}") + elif severities["unknown"] == 0: + print( + f"CRITICAL — {severities['critical']} sites are in critical state|{stats}" + ) + else: + print( + f"CRITICAL/UNKNOWN — {severities['critical']} sites are in critical state " + f"and {severities['unknown']} sites are in unknown state|{stats}" + ) + + sysexit(exit_nb) + + if __name__ == "__main__": cli() diff --git a/argos/config-example.yaml b/argos/config-example.yaml index a84cb89..763367c 100644 --- a/argos/config-example.yaml +++ b/argos/config-example.yaml @@ -23,7 +23,10 @@ general: frequency: "1m" # Which way do you want to be warned when a check goes to that severity? # "local" emits a message in the server log - # You’ll need to configure mail and gotify below to be able to use them here. + # You’ll need to configure mail, gotify or apprise below to be able to use + # them here. + # Use "apprise:john", "apprise:team" (with the quotes!) to use apprise + # notification groups. alerts: ok: - local @@ -58,6 +61,17 @@ general: # tokens: # - foo # - bar + # See https://github.com/caronc/apprise#productivity-based-notifications + # for apprise’s URLs syntax. + # You need to surround the URLs with quotes like in the examples below. + # Use "apprise:john", "apprise:team" (with the quotes!) in "alerts" settings. + # apprise: + # john: + # - "mastodon://access_key@hostname/@user" + # - "matrixs://token@hostname:port/?webhook=matrix" + # team: + # - "mmosts://user@hostname/authkey" + # - "nctalks://user:pass@host/RoomId1/RoomId2/RoomIdN" service: secrets: diff --git a/argos/schemas/config.py b/argos/schemas/config.py index f4887ca..520613a 100644 --- a/argos/schemas/config.py +++ b/argos/schemas/config.py @@ -168,6 +168,7 @@ class General(BaseModel): alerts: Alert mail: Optional[Mail] = None gotify: Optional[List[GotifyUrl]] = None + apprise: Optional[Dict[str, List[str]]] = None @field_validator("frequency", mode="before") def parse_frequency(cls, value): diff --git a/argos/server/alerting.py b/argos/server/alerting.py index bb833a2..64e6bc9 100644 --- a/argos/server/alerting.py +++ b/argos/server/alerting.py @@ -4,14 +4,24 @@ import smtplib from typing import List from urllib.parse import urlparse +import apprise 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 get_icon_from_severity(severity: str) -> str: + icon = "❌" + if severity == Severity.OK: + icon = "✅" + elif severity == Severity.WARNING: + icon = "⚠️" + elif severity == Severity.UNKNOWN: + icon = "❔" + + return icon def handle_alert(config: Config, result, task, severity, old_severity, request): @@ -39,12 +49,52 @@ def handle_alert(config: Config, result, task, severity, old_severity, request): result, task, severity, old_severity, config.general.gotify, request ) + if config.general.apprise is not None: + for notif_way in getattr(config.general.alerts, severity): + if notif_way.startswith("apprise:"): + group = notif_way[8:] + notify_with_apprise( + result, + task, + severity, + old_severity, + config.general.apprise[group], + request, + ) + + +def notify_with_apprise( + result, task, severity: str, old_severity: str, group: List[str], request +) -> None: + logger.debug("Will send apprise notification") + + apobj = apprise.Apprise() + for channel in group: + apobj.add(channel) + + icon = get_icon_from_severity(severity) + title = f"[Argos] {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 result on {request.url_for('get_result_view', result_id=result.id)} + +See results of task on {request.url_for('get_task_results_view', task_id=task.id)}#{result.id} +""" + + apobj.notify(title=title, body=msg) + def notify_by_mail( result, task, severity: str, old_severity: str, config: Mail, request ) -> None: logger.debug("Will send mail notification") + icon = get_icon_from_severity(severity) msg = f"""\ URL: {task.url} Check: {task.check} @@ -58,7 +108,7 @@ See results of task on {request.url_for('get_task_results_view', task_id=task.id """ mail = f"""\ -Subject: [Argos] {urlparse(task.url).netloc}: status {severity} +Subject: [Argos] {icon} {urlparse(task.url).netloc}: status {severity} {msg}""" @@ -92,29 +142,35 @@ def notify_with_gotify( logger.debug("Will send gotify notification") headers = {"accept": "application/json", "content-type": "application/json"} + icon = get_icon_from_severity(severity) priority = 9 - icon = "❌" if severity == Severity.OK: priority = 1 - icon = "✅" elif severity == Severity.WARNING: priority = 5 - icon = "⚠️" + elif severity == Severity.UNKNOWN: + priority = 5 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 result on {request.url_for('get_result_view', result_id=result.id)} - -See results of task on {request.url_for('get_task_results_view', task_id=task.id)}#{result.id} +URL:    <{task.url}>\\ +Check:  {task.check}\\ +Status: {severity}\\ +Time:   {result.submitted_at}\\ +Previous status: {old_severity}\\ +\\ +See result on <{request.url_for('get_result_view', result_id=result.id)}>\\ +\\ +See results of task on <{request.url_for('get_task_results_view', task_id=task.id)}#{result.id}> """ + extras = { + "client::display": {"contentType": "text/markdown"}, + "client::notification": { + "click": {"url": request.url_for("get_result_view", result_id=result.id)} + }, + } - payload = {"title": subject, "message": msg, "priority": priority} + payload = {"title": subject, "message": msg, "priority": priority, "extras": extras} for url in config: logger.debug("Sending gotify message(s) to %s", url) diff --git a/docs/cli.md b/docs/cli.md index 67a660d..cd17663 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -83,8 +83,11 @@ Commands: generate-config Output a self-documented example config file. generate-token Generate a token for agents migrate Run database migrations + nagios Nagios compatible severities report reload-config Load or reload tasks’ configuration start Starts the server (use only for testing or development!) + test-apprise Send a test apprise notification + test-gotify Send a test gotify notification test-mail Send a test email user User management watch-agents Watch agents (to run routinely) @@ -467,9 +470,33 @@ Options: +#### Use as a nagios probe + +You can directly use Argos to get an output and an exit code usable with Nagios. + + + +```man +Usage: argos server nagios [OPTIONS] + + Output a report of current severities suitable for Nagios with a Nagios + compatible exit code + +Options: + --config TEXT Path of the configuration file. If ARGOS_YAML_FILE environment + variable is set, its value will be used instead. + --help Show this message and exit. +``` + + + #### Test the email settings -You can verify that your mail settings are ok by sending a test email +You can verify that your mail settings are ok by sending a test email. + +#### Test the Gotify settings + +You can verify that your Gotify settings are ok by sending a test notification. + + + +```man +Usage: argos server test-gotify [OPTIONS] + + Send a test gotify notification + +Options: + --config TEXT Path of the configuration file. If ARGOS_YAML_FILE + environment variable is set, its value will be used instead. + --domain TEXT Domain for the notification + --severity TEXT Severity + --help Show this message and exit. +``` + + + +#### Test the Apprise settings + +You can verify that your Apprise settings are ok by sending a test notification. + + + +```man +Usage: argos server test-apprise [OPTIONS] + + Send a test apprise notification + +Options: + --config TEXT Path of the configuration file. If ARGOS_YAML_FILE + environment variable is set, its value will be used + instead. + --domain TEXT Domain for the notification + --severity TEXT Severity + --apprise-group TEXT Apprise group for the notification [required] + --help Show this message and exit. +``` + + diff --git a/pyproject.toml b/pyproject.toml index 119f643..dbb231e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,11 +22,12 @@ classifiers = [ dependencies = [ "alembic>=1.13.0,<1.14", + "apprise>=1.9.0,<2", "bcrypt>=4.1.3,<5", "click>=8.1,<9", "fastapi>=0.103,<0.104", "fastapi-login>=1.10.0,<2", - "httpx>=0.25,<0.27.0", + "httpx>=0.27.2,<1", "Jinja2>=3.0,<4", "jsonpointer>=3.0,<4", "passlib>=1.7.4,<2",