diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f02ddb4..1bd477d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,6 +13,14 @@ Change Log Unreleased ~~~~~~~~~~ + +[2.2.0] - 2023-10-27 +~~~~~~~~~~~~~~~~~~~~ + +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 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/__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..32d7d5f --- /dev/null +++ b/edx_arch_experiments/config_watcher/signals/receivers.py @@ -0,0 +1,123 @@ +""" +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): + """ + 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, + '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 according to config values. + + 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: + _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