diff --git a/engine/apps/alerts/integration_options_mixin.py b/engine/apps/alerts/integration_options_mixin.py index 2f4e0c24..f63afe30 100644 --- a/engine/apps/alerts/integration_options_mixin.py +++ b/engine/apps/alerts/integration_options_mixin.py @@ -17,9 +17,16 @@ class IntegrationOptionsMixin: super(IntegrationOptionsMixin, self).__init__(*args, **kwargs) # Object integration configs (imported as submodules earlier) are also available in `config` field, # e.g. instance.config.id, instance.config.slug, instance.config.description, etc... - for integration in self._config: - if integration.slug == self.integration: - self.config = integration + self.config = IntegrationOptionsMixin.get_config_from_type(self.integration) + + @classmethod + def get_config_from_type(cls, integration_type): + config = None + for integration in cls._config: + if integration.slug == integration_type: + config = integration + break + return config # Define variables for backward compatibility, e.g. INTEGRATION_GRAFANA, INTEGRATION_FORMATTED_WEBHOOK, etc... for integration_config in _config: diff --git a/engine/apps/alerts/migrations/0047_alertreceivechannel_additional_settings.py b/engine/apps/alerts/migrations/0047_alertreceivechannel_additional_settings.py new file mode 100644 index 00000000..10d20b54 --- /dev/null +++ b/engine/apps/alerts/migrations/0047_alertreceivechannel_additional_settings.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.10 on 2024-03-07 18:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0046_alertreceivechannelconnection'), + ] + + operations = [ + migrations.AddField( + model_name='alertreceivechannel', + name='additional_settings', + field=models.JSONField(default=None, null=True), + ), + ] diff --git a/engine/apps/alerts/models/alert_receive_channel.py b/engine/apps/alerts/models/alert_receive_channel.py index c35fbbb7..c56f441d 100644 --- a/engine/apps/alerts/models/alert_receive_channel.py +++ b/engine/apps/alerts/models/alert_receive_channel.py @@ -305,6 +305,8 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): alert_group_labels_template: str | None = models.TextField(null=True, default=None) """Stores a Jinja2 template for "advanced label templating" for alert group labels.""" + additional_settings: dict | None = models.JSONField(null=True, default=None) + class Meta: constraints = [ # This constraint ensures that there's at most one active direct paging integration per team diff --git a/engine/apps/api/serializers/alert_receive_channel.py b/engine/apps/api/serializers/alert_receive_channel.py index d7d01b45..a84896f9 100644 --- a/engine/apps/api/serializers/alert_receive_channel.py +++ b/engine/apps/api/serializers/alert_receive_channel.py @@ -26,6 +26,14 @@ from .integration_heartbeat import IntegrationHeartBeatSerializer from .labels import LabelsSerializerMixin +def _additional_settings_serializer_from_type(integration_type: str) -> serializers.Serializer: + """Return serializer class for given integration_type additional settings.""" + cls = None + config = AlertReceiveChannel.get_config_from_type(integration_type) + cls = getattr(config, "additional_settings_serializer", None) if config else None + return cls + + # AlertGroupCustomLabelValue represents custom alert group label value for API requests # It handles two types of label's value: # 1. Just Label Value from a label repo for a static label @@ -244,6 +252,7 @@ class AlertReceiveChannelSerializer( inbound_email = serializers.CharField(required=False, read_only=True) is_legacy = serializers.SerializerMethodField() alert_group_labels = IntegrationAlertGroupLabelsSerializer(source="*", required=False) + additional_settings = serializers.DictField(allow_null=True, allow_empty=False, required=False, default=None) # integration heartbeat is in PREFETCH_RELATED not by mistake. # With using of select_related ORM builds strange join @@ -286,6 +295,7 @@ class AlertReceiveChannelSerializer( "labels", "alert_group_labels", "alertmanager_v2_migrated_at", + "additional_settings", ] read_only_fields = [ "created_at", @@ -306,6 +316,28 @@ class AlertReceiveChannelSerializer( ] extra_kwargs = {"integration": {"required": True}} + def to_internal_value(self, data): + settings_serializer_cls = ( + _additional_settings_serializer_from_type(self.instance.config.slug) if self.instance else None + ) + if settings_serializer_cls: + additional_settings_data = data.get("additional_settings") + settings_serializer = settings_serializer_cls(self.instance, data=additional_settings_data) + settings_serializer.is_valid() + if settings_serializer.errors: + raise ValidationError({"additional_settings": settings_serializer.errors}) + data["additional_settings"] = settings_serializer.to_internal_value(additional_settings_data) + return super().to_internal_value(data) + + def to_representation(self, instance): + result = super().to_representation(instance) + if instance.additional_settings: + settings_serializer_cls = _additional_settings_serializer_from_type(instance.config.slug) + if settings_serializer_cls: + settings_serializer = settings_serializer_cls(instance) + result["additional_settings"] = settings_serializer.to_representation(instance) + return result + def validate(self, data): validated_data = super().validate(data) organization = self.context["request"].auth.organization @@ -396,6 +428,19 @@ class AlertReceiveChannelSerializer( return integration + def validate_additional_settings(self, data): + integration = self.instance.integration if self.instance else self.initial_data.get("integration") + settings_serializer_cls = _additional_settings_serializer_from_type(integration) + if settings_serializer_cls: + if not data: + raise ValidationError(["This field is required for this integration."]) + serializer = settings_serializer_cls(data=data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + elif data is not None: + raise ValidationError(["Invalid data"]) + return data + def get_allow_delete(self, obj: "AlertReceiveChannel") -> bool: # don't allow deleting direct paging integrations return obj.integration != AlertReceiveChannel.INTEGRATION_DIRECT_PAGING diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index 2b3dba1d..786190ba 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -3,7 +3,7 @@ from unittest.mock import ANY, patch import pytest from django.urls import reverse -from rest_framework import status +from rest_framework import serializers, status from rest_framework.response import Response from rest_framework.test import APIClient @@ -12,6 +12,23 @@ from apps.api.permissions import LegacyAccessControlRole from apps.labels.models import LabelKeyCache, LabelValueCache +class AdditionalSettingsTestSerializer(serializers.Serializer): + instance_url = serializers.CharField(required=True) + + def validate(self, data): + if hasattr(self, "initial_data"): + unknown_fields = set(self.initial_data.keys()) - set(self.fields.keys()) + if unknown_fields: + raise serializers.ValidationError("Unexpected fields: {}".format(unknown_fields)) + return data + + def to_internal_value(self, data): + return super().to_internal_value(data) + + def to_representation(self, instance): + return super().to_representation(instance.additional_settings) + + @pytest.fixture() def alert_receive_channel_internal_api_setup( make_organization_and_user_with_plugin_token, @@ -1729,6 +1746,139 @@ def test_team_not_updated_if_not_in_data( assert alert_receive_channel.team == team +@pytest.mark.django_db +def test_create_additional_settings_integration( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, +): + _, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + + # set up additional settings for an integration + integration = AlertReceiveChannel._config[0] + integration.additional_settings_serializer = AdditionalSettingsTestSerializer + + url = reverse("api-internal:alert_receive_channel-list") + # create without additional_settings + data = { + "integration": integration.slug, + "team": None, + } + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # create with empty additional_settings + data = {"integration": integration.slug, "team": None, "additional_settings": {}} + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "additional_settings" in response.json() + + # create with wrong additional_settings + data = { + "integration": integration.slug, + "team": None, + "additional_settings": {"test": "test"}, + } + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # create with correct additional_settings + data = { + "integration": integration.slug, + "team": None, + "additional_settings": {"instance_url": "test"}, + } + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_201_CREATED + + +@pytest.mark.django_db +def test_update_additional_settings_integration( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + settings = {"instance_url": "test"} + + # set up additional settings for an integration + integration = AlertReceiveChannel._config[0] + integration.additional_settings_serializer = AdditionalSettingsTestSerializer + + alert_receive_channel = make_alert_receive_channel( + organization, integration=integration.slug, additional_settings=settings + ) + + url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key}) + # wrong additional_settings + data = {"additional_settings": {"test": "test", "username": "test", "password": "test"}} + response = client.put(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "additional_settings" in response.json() + + data = {"additional_settings": {}} + response = client.put(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "additional_settings" in response.json() + + data = {"additional_settings": None} + response = client.put(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "additional_settings" in response.json() + + data = { + "additional_settings": { + "test": "test", + "instance_url": "test2", + } + } + response = client.put(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "additional_settings" in response.json() + + # update with correct settings + data = { + "additional_settings": { + "instance_url": "test2", + } + } + response = client.put(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + + alert_receive_channel.refresh_from_db() + assert alert_receive_channel.additional_settings == data["additional_settings"] + + +@pytest.mark.django_db +def test_update_other_integration_additional_settings( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + alert_receive_channel = make_alert_receive_channel(organization) + url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key}) + # integration doesn't have additional_settings + data = { + "additional_settings": { + "instance_url": "test", + "username": "test", + "password": "test", + "is_configured": True, + "state_mapping": { + "firing": [1, "New"], + "acknowledged": None, + "resolved": None, + "silenced": None, + }, + } + } + response = client.put(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def _webhook_data(webhook_id=ANY, webhook_name=ANY, webhook_url=ANY, alert_receive_channel_id=ANY): return { "authorization_header": None,