diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fce7583..3d763d4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,13 @@ Change Log Unreleased ~~~~~~~~~~ +[3.3.0] - 2024-01-23 +~~~~~~~~~~~~~~~~~~~~ +Changed +_______ +* Updated ``ConfigWatcher`` to include the IDA's name in change messages if ``CONFIG_WATCHER_SERVICE_NAME`` is set +* Enabled ``ConfigWatcher`` as a plugin for CMS + [3.2.0] - 2024-01-11 ~~~~~~~~~~~~~~~~~~~~ Added diff --git a/edx_arch_experiments/__init__.py b/edx_arch_experiments/__init__.py index 0960636..2bf1c4b 100644 --- a/edx_arch_experiments/__init__.py +++ b/edx_arch_experiments/__init__.py @@ -2,4 +2,4 @@ A plugin to include applications under development by the architecture team at 2U. """ -__version__ = '3.2.0' +__version__ = '3.3.0' diff --git a/edx_arch_experiments/config_watcher/README.rst b/edx_arch_experiments/config_watcher/README.rst new file mode 100644 index 0000000..e59e758 --- /dev/null +++ b/edx_arch_experiments/config_watcher/README.rst @@ -0,0 +1,8 @@ +ConfigWatcher +############# + +Plugin app that can report on changes to Django model instances via logging and optionally Slack messages. The goal is to help operators who are investigating an outage or other sudden change in behavior by allowing them to easily determine what has changed recently. + +Currently specialized to observe Waffle flags, switches, and samples, but could be expanded to other models. + +See ``.signals.receivers`` for available settings and ``/setup.py`` for IDA plugin configuration. diff --git a/edx_arch_experiments/config_watcher/signals/receivers.py b/edx_arch_experiments/config_watcher/signals/receivers.py index f92e403..bf5d846 100644 --- a/edx_arch_experiments/config_watcher/signals/receivers.py +++ b/edx_arch_experiments/config_watcher/signals/receivers.py @@ -9,10 +9,10 @@ import logging import urllib.request -import waffle.models from django.conf import settings from django.db.models import signals from django.dispatch import receiver +from django.utils.module_loading import import_string log = logging.getLogger(__name__) @@ -22,12 +22,23 @@ # If not configured, this functionality is disabled. CONFIG_WATCHER_SLACK_WEBHOOK_URL = getattr(settings, 'CONFIG_WATCHER_SLACK_WEBHOOK_URL', None) +# .. setting_name: CONFIG_WATCHER_SERVICE_NAME +# .. setting_default: None +# .. setting_description: Name of service, to be included in Slack messages in +# in order to distinguish messages from multiple services being aggregated in +# one channel. Can be a regular name ("LMS"), hostname, ("courses.example.com"), +# or any other string. Optional. +CONFIG_WATCHER_SERVICE_NAME = getattr(settings, 'CONFIG_WATCHER_SERVICE_NAME', None) + def _send_to_slack(message): """Send this message as plain text to the configured Slack channel.""" if not CONFIG_WATCHER_SLACK_WEBHOOK_URL: return + if CONFIG_WATCHER_SERVICE_NAME: + message = f"[{CONFIG_WATCHER_SERVICE_NAME}] {message}" + # https://api.slack.com/reference/surfaces/formatting body_data = { 'text': html.escape(message, quote=False) @@ -73,17 +84,17 @@ def _report_waffle_delete(model_short_name, instance): # keyword args of _register_waffle_observation. _WAFFLE_MODELS_TO_OBSERVE = [ { - 'model': waffle.models.Flag, + 'model': 'waffle.models.Flag', 'short_name': 'flag', 'fields': ['everyone', 'percent', 'note'], }, { - 'model': waffle.models.Switch, + 'model': 'waffle.models.Switch', 'short_name': 'switch', 'fields': ['active', 'note'], }, { - 'model': waffle.models.Sample, + 'model': 'waffle.models.Sample', 'short_name': 'sample', 'fields': ['percent', 'note'], }, @@ -95,11 +106,13 @@ def _register_waffle_observation(*, model, short_name, fields): Register a Waffle model for observation according to config values. Args: - model (class): The model class to monitor + model (str): The model class to monitor, as a dotted string reference short_name (str): A short descriptive name for an instance of the model, e.g. "flag" fields (list): Names of fields to report on in the Slack message """ + model = import_string(model) + # Note that weak=False is required here. Django by default only # holds weak references to receiver functions. But these inner # functions would then be garbage-collected, and Django would drop diff --git a/edx_arch_experiments/config_watcher/signals/tests/test_receivers.py b/edx_arch_experiments/config_watcher/signals/tests/test_receivers.py new file mode 100644 index 0000000..e07572b --- /dev/null +++ b/edx_arch_experiments/config_watcher/signals/tests/test_receivers.py @@ -0,0 +1,48 @@ +""" +Test ConfigWatcher signal receivers (main code). +""" + +import json +from contextlib import ExitStack +from unittest.mock import call, patch + +import ddt +from django.test import TestCase, override_settings + +from edx_arch_experiments.config_watcher.signals import receivers + + +@ddt.ddt +class TestConfigWatcherReceivers(TestCase): + + @ddt.unpack + @ddt.data( + ( + None, None, None, + ), + ( + 'https://localhost', None, "test message", + ), + ( + 'https://localhost', 'my-ida', "[my-ida] test message", + ), + ) + def test_send_to_slack(self, slack_url, service_name, expected_message): + """Check that message prefixing is performed as expected.""" + # This can be made cleaner in Python 3.10 + with ExitStack() as stack: + patches = [ + patch('urllib.request.Request'), + patch('urllib.request.urlopen'), + patch.object(receivers, 'CONFIG_WATCHER_SLACK_WEBHOOK_URL', slack_url), + patch.object(receivers, 'CONFIG_WATCHER_SERVICE_NAME', service_name), + ] + (mock_req, _, _, _) = [stack.enter_context(cm) for cm in patches] + receivers._send_to_slack("test message") + + if expected_message is None: + mock_req.assert_not_called() + else: + assert mock_req.called_once() + (call_args, call_kwargs) = mock_req.call_args_list[0] + assert json.loads(call_kwargs['data'])['text'] == expected_message diff --git a/setup.py b/setup.py index 4b0d2af..a7ee121 100644 --- a/setup.py +++ b/setup.py @@ -165,5 +165,8 @@ def is_requirement(line): "config_watcher = edx_arch_experiments.config_watcher.apps:ConfigWatcher", "codejail_service = edx_arch_experiments.codejail_service.apps:CodejailService", ], + "cms.djangoapp": [ + "config_watcher = edx_arch_experiments.config_watcher.apps:ConfigWatcher", + ], }, )