Repository: jamesoff/simplemonitor

Issue:
Support documentation fields in monitors
Add one or more documentation fields to Monitors, so they can include information such as a link to a run/playbook which Alerters can include.

Discussion:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 861cdd6..d32f204 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -5,6 +5,7 @@ repos:
     -   id: check-yaml
     -   id: end-of-file-fixer
     -   id: trailing-whitespace
+        exclude: tests/test_alerter.py
     -   id: flake8
 -   repo: https://github.com/psf/black
     rev: 19.10b0
diff --git a/docs/_includes/monitors.html b/docs/_includes/monitors.html
index dab1044..e8b9d83 100644
--- a/docs/_includes/monitors.html
+++ b/docs/_includes/monitors.html
@@ -21,6 +21,7 @@ All monitor types share the following configuration options:
 |recovered_command| A command to execute once when this monitor succeeds the first time after being failed.|no| |
 |group|The group the monitor belongs to. Alerters will only fire for monitors which appear in their groups.|no|`default`|
 |notify|If the monitor should alert at all|no|1|
+|failure_doc|Information to include in alerts on failure (e.g. a URL to a runbook)|no| |
 {% for monitor in m %}
 <a name="{{monitor.name}}"></a>
 
diff --git a/simplemonitor/Alerters/alerter.py b/simplemonitor/Alerters/alerter.py
index ecaf962..78d5d70 100644
--- a/simplemonitor/Alerters/alerter.py
+++ b/simplemonitor/Alerters/alerter.py
@@ -345,7 +345,13 @@ class Alerter:
                 Description: {desc}
                 """
                 if monitor.recover_info != "":
-                    message = message + "Recovery info: {}".format(monitor.recover_info)
+                    message = message + "Recovery info: {}\n".format(
+                        monitor.recover_info
+                    )
+                if monitor.failure_doc:
+                    message = message + "Documentation: {}\n".format(
+                        monitor.failure_doc
+                    )
             elif alert_type == AlertType.SUCCESS:
                 message = """
                 Monitor {monitor.name}{host} {alert_verb}!
diff --git a/simplemonitor/Monitors/monitor.py b/simplemonitor/Monitors/monitor.py
index 724bad3..5dbae2f 100644
--- a/simplemonitor/Monitors/monitor.py
+++ b/simplemonitor/Monitors/monitor.py
@@ -86,6 +86,9 @@ class Monitor:
         self.minimum_gap = self.get_config_option(
             "gap", required_type="int", minimum=0, default=0
         )
+        self.failure_doc = cast(
+            Optional[str], self.get_config_option("failure_doc", default=None)
+        )
 
         self.running_on = short_hostname()
         self._state = MonitorState.UNKNOWN


Test patch:
diff --git a/tests/test_alerter.py b/tests/test_alerter.py
index 41612e8..471415e 100644
--- a/tests/test_alerter.py
+++ b/tests/test_alerter.py
@@ -1,5 +1,6 @@
 # type: ignore
 import datetime
+import textwrap
 import unittest
 
 from freezegun import freeze_time
@@ -288,6 +289,153 @@ class TestAlerter(unittest.TestCase):
             self.assertEqual(a._ooh_failures, [])
 
 
+class TestMessageBuilding(unittest.TestCase):
+    def test_notification_format_failure(self):
+        m = monitor.MonitorFail("test", {})
+        with freeze_time("2020-03-10 09:00"):
+            m.run_test()
+            self.assertEqual(
+                alerter.Alerter.build_message(
+                    alerter.AlertLength.NOTIFICATION, alerter.AlertType.FAILURE, m
+                ),
+                "Monitor test failed",
+            )
+
+    def test_notification_format_success(self):
+        m = monitor.MonitorNull("winning", {})
+        with freeze_time("2020-03-10 09:00"):
+            for _ in range(0, 6):
+                m.run_test()
+            self.assertEqual(
+                alerter.Alerter.build_message(
+                    alerter.AlertLength.NOTIFICATION, alerter.AlertType.SUCCESS, m
+                ),
+                "Monitor winning succeeded",
+            )
+
+    def test_oneline_format_failure(self):
+        m = monitor.MonitorFail("test", {})
+        with freeze_time("2020-03-10 09:00"):
+            m.run_test()
+            self.assertEqual(
+                alerter.Alerter.build_message(
+                    alerter.AlertLength.ONELINE, alerter.AlertType.FAILURE, m
+                ),
+                "failure: test failed on {hostname} at 2020-03-10 09:00:00 (0+00:00:00): This monitor always fails.".format(
+                    hostname=util.short_hostname()
+                ),
+            )
+
+    def test_oneline_format_success(self):
+        m = monitor.MonitorNull("winning", {})
+        with freeze_time("2020-03-10 09:00"):
+            for _ in range(0, 6):
+                m.run_test()
+            m.last_result = "a " * 80
+            desired = (
+                "success: winning succeeded on {hostname} at  (0+00:00:00): ".format(
+                    hostname=util.short_hostname()
+                )
+                + "a " * 80
+            )
+            output = alerter.Alerter.build_message(
+                alerter.AlertLength.ONELINE, alerter.AlertType.SUCCESS, m
+            )
+            self.assertEqual(desired, output)
+
+    def test_sms_format_failure(self):
+        m = monitor.MonitorFail("test", {})
+        with freeze_time("2020-03-10 09:00"):
+            m.run_test()
+            self.assertEqual(
+                alerter.Alerter.build_message(
+                    alerter.AlertLength.SMS, alerter.AlertType.FAILURE, m
+                ),
+                "failure: test failed on {hostname} at 2020-03-10 09:00:00 (0+00:00:00): This monitor always fails.".format(
+                    hostname=util.short_hostname()
+                ),
+            )
+
+    def test_sms_format_success(self):
+        m = monitor.MonitorNull("winning", {})
+        with freeze_time("2020-03-10 09:00"):
+            for _ in range(0, 6):
+                m.run_test()
+            m.last_result = "a " * 80
+            self.assertEqual(
+                alerter.Alerter.build_message(
+                    alerter.AlertLength.SMS, alerter.AlertType.SUCCESS, m
+                ),
+                "success: winning succeeded on {hostname} at (0+00:00:00): a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a...".format(
+                    hostname=util.short_hostname()
+                ),
+            )
+
+    def test_full_format_failure(self):
+        m = monitor.MonitorFail("test", {})
+        with freeze_time("2020-03-10 09:00"):
+            m.run_test()
+            self.assertEqual(
+                alerter.Alerter.build_message(
+                    alerter.AlertLength.FULL, alerter.AlertType.FAILURE, m
+                ),
+                textwrap.dedent(
+                    """
+                    Monitor test on {hostname} failed!
+                    Failed at: 2020-03-10 09:00:00 (down 0+00:00:00)
+                    Virtual failure count: 1
+                    Additional info: This monitor always fails.
+                    Description: A monitor which always fails.
+                    """.format(
+                        hostname=util.short_hostname()
+                    )
+                ),
+            )
+
+    def test_full_format_failure_docs(self):
+        m = monitor.MonitorFail("test", {"failure_doc": "whoops"})
+        with freeze_time("2020-03-10 09:00"):
+            m.run_test()
+            self.assertEqual(
+                alerter.Alerter.build_message(
+                    alerter.AlertLength.FULL, alerter.AlertType.FAILURE, m
+                ),
+                textwrap.dedent(
+                    """
+                    Monitor test on {hostname} failed!
+                    Failed at: 2020-03-10 09:00:00 (down 0+00:00:00)
+                    Virtual failure count: 1
+                    Additional info: This monitor always fails.
+                    Description: A monitor which always fails.
+                    Documentation: whoops
+                    """.format(
+                        hostname=util.short_hostname()
+                    )
+                ),
+            )
+
+    def test_full_format_success(self):
+        m = monitor.MonitorNull("winning", {})
+        with freeze_time("2020-03-10 09:00"):
+            for _ in range(0, 6):
+                m.run_test()
+            self.assertEqual(
+                alerter.Alerter.build_message(
+                    alerter.AlertLength.FULL, alerter.AlertType.SUCCESS, m
+                ),
+                textwrap.dedent(
+                    """
+                    Monitor winning on {hostname} succeeded!
+                    Recovered at: 
+                    Additional info: 
+                    Description: (Monitor did not write an auto-biography.)
+                    """.format(  # noqa: W291
+                        hostname=util.short_hostname()
+                    )
+                ),
+            )
+
+
 class TestSNSAlerter(unittest.TestCase):
     def test_config(self):
         with self.assertRaises(util.AlerterConfigurationError):