From afeb4d4f734948e87f86debd97180d2cf4f34d73 Mon Sep 17 00:00:00 2001 From: Tim McCormack Date: Mon, 23 Oct 2023 15:23:59 +0000 Subject: [PATCH 1/3] feat: Add Waffle config watcher Listens to Waffle model changes and reports them 1) to the log and 2) to a Slack webhook. This adds a dependency on django-waffle. --- CHANGELOG.rst | 1 + .../config_watcher/__init__.py | 0 edx_arch_experiments/config_watcher/apps.py | 17 +++ .../config_watcher/signals/__init__.py | 0 .../config_watcher/signals/receivers.py | 111 ++++++++++++++++++ requirements/base.in | 1 + requirements/base.txt | 4 +- 7 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 edx_arch_experiments/config_watcher/__init__.py create mode 100644 edx_arch_experiments/config_watcher/apps.py create mode 100644 edx_arch_experiments/config_watcher/signals/__init__.py create mode 100644 edx_arch_experiments/config_watcher/signals/receivers.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f02ddb4..6391d7e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,7 @@ Unreleased ~~~~~~~~~~ * Add script to get github action errors * Add script to republish failed events +* Add ``edx_arch_experiments.config_watcher`` Django app for monitoring Waffle changes [2.1.0] - 2023-10-10 ~~~~~~~~~~~~~~~~~~~~ diff --git a/edx_arch_experiments/config_watcher/__init__.py b/edx_arch_experiments/config_watcher/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/edx_arch_experiments/config_watcher/apps.py b/edx_arch_experiments/config_watcher/apps.py new file mode 100644 index 0000000..afb6d97 --- /dev/null +++ b/edx_arch_experiments/config_watcher/apps.py @@ -0,0 +1,17 @@ +""" +App for reporting configuration changes to Slack for operational awareness. +""" + +from django.apps import AppConfig + + +class ConfigWatcherApp(AppConfig): + """ + Django application to report configuration changes to operators. + """ + name = 'edx_arch_experiments.config_watcher' + + def ready(self): + from .signals import receivers # pylint: disable=import-outside-toplevel + + receivers.connect_receivers() diff --git a/edx_arch_experiments/config_watcher/signals/__init__.py b/edx_arch_experiments/config_watcher/signals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/edx_arch_experiments/config_watcher/signals/receivers.py b/edx_arch_experiments/config_watcher/signals/receivers.py new file mode 100644 index 0000000..b5e04a2 --- /dev/null +++ b/edx_arch_experiments/config_watcher/signals/receivers.py @@ -0,0 +1,111 @@ +""" +Signal receivers for the config watcher. + +Call ``connect_receivers`` to initialize. +""" + +import html +import json +import logging +import urllib.request + +import waffle.models +from django.conf import settings +from django.db.models import signals +from django.dispatch import receiver + +log = logging.getLogger(__name__) + +# .. setting_name: CONFIG_WATCHER_SLACK_WEBHOOK_URL +# .. setting_default: None +# .. setting_description: Slack webhook URL to send config change events to. +# If not configured, this functionality is disabled. +CONFIG_WATCHER_SLACK_WEBHOOK_URL = getattr(settings, 'CONFIG_WATCHER_SLACK_WEBHOOK_URL', 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 + + # https://api.slack.com/reference/surfaces/formatting + body_data = { + 'text': html.escape(message, quote=False) + } + + req = urllib.request.Request( + url=CONFIG_WATCHER_SLACK_WEBHOOK_URL, data=json.dumps(body_data).encode(), + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=2) as resp: + status = resp.getcode() + if status != 200: + log.error(f"Slack rejected the config watcher message. {status=}, body={resp.read().decode('utf-8')}") + + +def _report_config_change(message): + log.info(message) + _send_to_slack(message) + + +def _report_waffle_change(model_short_name, instance, created, fields): + verb = "created" if created else "updated" + state_desc = ", ".join(f"{field}={repr(getattr(instance, field))}" for field in fields) + _report_config_change(f"Waffle {model_short_name} {instance.name!r} was {verb}. New config: {state_desc}") + + +def _report_waffle_delete(model_short_name, instance): + _report_config_change(f"Waffle {model_short_name} {instance.name!r} was deleted") + + +_WAFFLE_MODELS_TO_OBSERVE = [ + { + 'model': waffle.models.Flag, + 'short_name': 'flag', + 'fields': ['everyone', 'percent', 'superusers', 'staff', 'authenticated', 'note', 'languages'], + }, + { + 'model': waffle.models.Switch, + 'short_name': 'switch', + 'fields': ['active', 'note'], + }, + { + 'model': waffle.models.Sample, + 'short_name': 'sample', + 'fields': ['percent', 'note'], + }, +] + + +def _register_waffle_observation(*, model, short_name, fields): + """ + Register a Waffle model for observation. + + Args: + model (class): The model class to monitor + 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 + """ + @receiver(signals.post_save, sender=model) + def report_waffle_change(*args, instance, created, **kwargs): + try: + _report_waffle_change(short_name, instance, created, fields) + except: # noqa pylint: disable=bare-except + # Log and suppress error so Waffle change can proceed + log.exception(f"Failed to report change to waffle {short_name}") + + @receiver(signals.post_delete, sender=model) + def report_waffle_delete(*args, instance, **kwargs): + try: + _report_waffle_delete(short_name, instance) + except: # noqa pylint: disable=bare-except + log.exception(f"Failed to report deletion of waffle {short_name}") + + +def connect_receivers(): + """ + Initialize application's receivers. + """ + for config in _WAFFLE_MODELS_TO_OBSERVE: + # Pass config to function to capture value properly. + _register_waffle_observation(**config) diff --git a/requirements/base.in b/requirements/base.in index ffe4f6c..0935bef 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -3,3 +3,4 @@ Django # Web application framework edx_django_utils +django-waffle # Configuration switches and flags -- used by config_watcher app diff --git a/requirements/base.txt b/requirements/base.txt index 89e1d84..8a2b29e 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -20,7 +20,9 @@ django==3.2.22 django-crum==0.7.9 # via edx-django-utils django-waffle==4.0.0 - # via edx-django-utils + # via + # -r requirements/base.in + # edx-django-utils edx-django-utils==5.7.0 # via -r requirements/base.in newrelic==9.1.0 From aa6ee603672cb91e0b5c40fa329ba88b7a24a076 Mon Sep 17 00:00:00 2001 From: Tim McCormack Date: Thu, 26 Oct 2023 18:12:40 +0000 Subject: [PATCH 2/3] fixup! Add changelog, comments, docstrings, version change --- CHANGELOG.rst | 9 ++++++++- edx_arch_experiments/__init__.py | 2 +- .../config_watcher/signals/receivers.py | 16 ++++++++++++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6391d7e..7ff04b2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,9 +13,16 @@ Change Log Unreleased ~~~~~~~~~~ + +[2.2.0] - 2023-10-26 +~~~~~~~~~~~~~~~~~~~~ + +Added +_____ + +* Add ``edx_arch_experiments.config_watcher`` Django app for monitoring Waffle changes * Add script to get github action errors * Add script to republish failed events -* Add ``edx_arch_experiments.config_watcher`` Django app for monitoring Waffle changes [2.1.0] - 2023-10-10 ~~~~~~~~~~~~~~~~~~~~ diff --git a/edx_arch_experiments/__init__.py b/edx_arch_experiments/__init__.py index efb7336..f0eaad3 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__ = '2.1.0' +__version__ = '2.2.0' diff --git a/edx_arch_experiments/config_watcher/signals/receivers.py b/edx_arch_experiments/config_watcher/signals/receivers.py index b5e04a2..32d7d5f 100644 --- a/edx_arch_experiments/config_watcher/signals/receivers.py +++ b/edx_arch_experiments/config_watcher/signals/receivers.py @@ -44,20 +44,33 @@ def _send_to_slack(message): def _report_config_change(message): + """ + Report this message string as a configuration change. + + Sends to logs and to Slack. + """ log.info(message) _send_to_slack(message) def _report_waffle_change(model_short_name, instance, created, fields): + """ + Report that a model instance has been created or updated. + """ verb = "created" if created else "updated" state_desc = ", ".join(f"{field}={repr(getattr(instance, field))}" for field in fields) _report_config_change(f"Waffle {model_short_name} {instance.name!r} was {verb}. New config: {state_desc}") def _report_waffle_delete(model_short_name, instance): + """ + Report that a model instance has been deleted. + """ _report_config_change(f"Waffle {model_short_name} {instance.name!r} was deleted") +# List of models to observe. Each is a dictionary that matches the +# keyword args of _register_waffle_observation. _WAFFLE_MODELS_TO_OBSERVE = [ { 'model': waffle.models.Flag, @@ -79,7 +92,7 @@ def _report_waffle_delete(model_short_name, instance): def _register_waffle_observation(*, model, short_name, fields): """ - Register a Waffle model for observation. + Register a Waffle model for observation according to config values. Args: model (class): The model class to monitor @@ -107,5 +120,4 @@ def connect_receivers(): Initialize application's receivers. """ for config in _WAFFLE_MODELS_TO_OBSERVE: - # Pass config to function to capture value properly. _register_waffle_observation(**config) From c21ab17d1aed80ea4f3ae07fea80519bb07de96b Mon Sep 17 00:00:00 2001 From: Tim McCormack Date: Fri, 27 Oct 2023 14:14:02 +0000 Subject: [PATCH 3/3] fixup! Changelog date --- CHANGELOG.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7ff04b2..1bd477d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,7 +14,7 @@ Change Log Unreleased ~~~~~~~~~~ -[2.2.0] - 2023-10-26 +[2.2.0] - 2023-10-27 ~~~~~~~~~~~~~~~~~~~~ Added