diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eb1940b9..addbb371 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -61,6 +61,7 @@ repos: - eslint@^8.25.0 - eslint-plugin-import@^2.25.4 - eslint-plugin-rulesdir@^0.2.1 + - eslint-plugin-unused-imports@^3.1.0 - "@grafana/eslint-config@^5.0.0" - repo: https://github.com/pre-commit/mirrors-prettier diff --git a/engine/apps/alerts/migrations/0048_alertgroupexternalid.py b/engine/apps/alerts/migrations/0048_alertgroupexternalid.py new file mode 100644 index 00000000..8f1fc1ca --- /dev/null +++ b/engine/apps/alerts/migrations/0048_alertgroupexternalid.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.10 on 2024-03-19 19:01 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0047_alertreceivechannel_additional_settings'), + ] + + operations = [ + migrations.CreateModel( + name='AlertGroupExternalID', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.CharField(max_length=512)), + ('alert_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='external_ids', to='alerts.alertgroup')), + ('source_alert_receive_channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='external_ids', to='alerts.alertreceivechannel')), + ], + options={ + 'indexes': [models.Index(fields=['value'], name='alerts_aler_value_69ba4f_idx')], + 'unique_together': {('source_alert_receive_channel', 'alert_group')}, + }, + ), + ] diff --git a/engine/apps/alerts/models/__init__.py b/engine/apps/alerts/models/__init__.py index 5ca0bb36..c537fc31 100644 --- a/engine/apps/alerts/models/__init__.py +++ b/engine/apps/alerts/models/__init__.py @@ -4,6 +4,7 @@ from .alert_group_counter import AlertGroupCounter # noqa: F401 from .alert_group_log_record import AlertGroupLogRecord, listen_for_alertgrouplogrecord # noqa: F401 from .alert_manager_models import AlertForAlertManager, AlertGroupForAlertManager # noqa: F401 from .alert_receive_channel import AlertReceiveChannel, listen_for_alertreceivechannel_model_save # noqa: F401 +from .alert_receive_channel_connection import AlertGroupExternalID # noqa: F401 from .alert_receive_channel_connection import AlertReceiveChannelConnection # noqa: F401 from .channel_filter import ChannelFilter # noqa: F401 from .custom_button import CustomButton # noqa: F401 diff --git a/engine/apps/alerts/models/alert_receive_channel_connection.py b/engine/apps/alerts/models/alert_receive_channel_connection.py index 19d9f73e..f177c81b 100644 --- a/engine/apps/alerts/models/alert_receive_channel_connection.py +++ b/engine/apps/alerts/models/alert_receive_channel_connection.py @@ -18,3 +18,22 @@ class AlertReceiveChannelConnection(models.Model): class Meta: ordering = ["source_alert_receive_channel", "connected_alert_receive_channel"] unique_together = ("source_alert_receive_channel", "connected_alert_receive_channel") + + +class AlertGroupExternalID(models.Model): + """ + This model represents an external ID for an alert group. This is used to keep track of the alert group in + the external system (e.g. ServiceNow). + """ + + source_alert_receive_channel = models.ForeignKey( + "AlertReceiveChannel", on_delete=models.CASCADE, related_name="external_ids" + ) + alert_group = models.ForeignKey("AlertGroup", on_delete=models.CASCADE, related_name="external_ids") + value = models.CharField(max_length=512) + + class Meta: + unique_together = ("source_alert_receive_channel", "alert_group") + indexes = [ + models.Index(fields=["value"]), + ] diff --git a/engine/apps/alerts/signals.py b/engine/apps/alerts/signals.py index 31abc93b..a01d06cf 100644 --- a/engine/apps/alerts/signals.py +++ b/engine/apps/alerts/signals.py @@ -47,3 +47,14 @@ alert_group_update_resolution_note_signal.connect( user_notification_action_triggered_signal.connect( UserSlackRepresentative.on_user_action_triggered, ) + + +def integration_config_on_alert_group_created(**kwargs): + alert_group = kwargs["alert_group"] + config = alert_group.channel.config + if hasattr(config, "on_alert_group_created"): + config.on_alert_group_created(alert_group) + + +# using the "alert_group_escalation_snapshot_built" signal to make sure at least one alert exists for the alert group +alert_group_escalation_snapshot_built.connect(integration_config_on_alert_group_created) diff --git a/engine/apps/alerts/tests/test_alert_group.py b/engine/apps/alerts/tests/test_alert_group.py index 0d7166c6..8101b31c 100644 --- a/engine/apps/alerts/tests/test_alert_group.py +++ b/engine/apps/alerts/tests/test_alert_group.py @@ -4,7 +4,7 @@ import pytest from apps.alerts.constants import ActionSource from apps.alerts.incident_appearance.renderers.phone_call_renderer import AlertGroupPhoneCallRenderer -from apps.alerts.models import AlertGroup, AlertGroupLogRecord +from apps.alerts.models import Alert, AlertGroup, AlertGroupLogRecord from apps.alerts.tasks import wipe from apps.alerts.tasks.delete_alert_group import ( delete_alert_group, @@ -665,3 +665,26 @@ def test_delete_by_user( for dependent_alert_group in dependent_alert_groups: dependent_alert_group.un_attach_by_delete.assert_called_with() + + +@pytest.mark.django_db +def test_integration_config_on_alert_group_created(make_organization, make_alert_receive_channel, make_channel_filter): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization, grouping_id_template="group_to_one_group") + + with patch.object( + alert_receive_channel.config, "on_alert_group_created", create=True + ) as mock_on_alert_group_created: + for _ in range(2): + alert = Alert.create( + title="the title", + message="the message", + alert_receive_channel=alert_receive_channel, + raw_request_data={}, + integration_unique_data={}, + image_url=None, + link_to_upstream_details=None, + ) + + assert alert.group.alerts.count() == 2 + mock_on_alert_group_created.assert_called_once_with(alert.group) diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index a718cdfb..132c09bf 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -2231,10 +2231,15 @@ def test_connected_alert_receive_channels_get( def test_connected_alert_receive_channels_post( make_organization_and_user_with_plugin_token, make_alert_receive_channel, + make_custom_webhook, make_user_auth_headers, ): organization, user, token = make_organization_and_user_with_plugin_token() + source_alert_receive_channel = make_alert_receive_channel(organization) + webhook = make_custom_webhook(organization, is_from_connected_integration=True) + webhook.filtered_integrations.set([source_alert_receive_channel]) + alert_receive_channel_to_connect_1 = make_alert_receive_channel(organization) alert_receive_channel_to_connect_2 = make_alert_receive_channel(organization) @@ -2278,6 +2283,11 @@ def test_connected_alert_receive_channels_post( ], } assert source_alert_receive_channel.connected_alert_receive_channels.count() == 2 + assert set(webhook.filtered_integrations.values_list("id", flat=True)) == { + source_alert_receive_channel.id, + alert_receive_channel_to_connect_1.id, + alert_receive_channel_to_connect_2.id, + } @pytest.mark.django_db @@ -2322,6 +2332,7 @@ def test_connected_alert_receive_channels_delete( make_organization_and_user_with_plugin_token, make_alert_receive_channel, make_alert_receive_channel_connection, + make_custom_webhook, make_user_auth_headers, ): organization, user, token = make_organization_and_user_with_plugin_token() @@ -2333,6 +2344,11 @@ def test_connected_alert_receive_channels_delete( make_alert_receive_channel_connection(source_alert_receive_channel, connected_alert_receive_channel_1) make_alert_receive_channel_connection(source_alert_receive_channel, connected_alert_receive_channel_2) + webhook = make_custom_webhook(organization, is_from_connected_integration=True) + webhook.filtered_integrations.set( + [source_alert_receive_channel, connected_alert_receive_channel_1, connected_alert_receive_channel_2] + ) + client = APIClient() url = reverse( "api-internal:alert_receive_channel-connected-alert-receive-channels-put", @@ -2349,6 +2365,10 @@ def test_connected_alert_receive_channels_delete( source_alert_receive_channel.connected_alert_receive_channels.first().connected_alert_receive_channel == connected_alert_receive_channel_2 ) + assert set(webhook.filtered_integrations.values_list("id", flat=True)) == { + source_alert_receive_channel.id, + connected_alert_receive_channel_2.id, + } @pytest.mark.django_db diff --git a/engine/apps/api/views/alert_receive_channel.py b/engine/apps/api/views/alert_receive_channel.py index f856bc1c..55371916 100644 --- a/engine/apps/api/views/alert_receive_channel.py +++ b/engine/apps/api/views/alert_receive_channel.py @@ -777,6 +777,9 @@ class AlertReceiveChannelView( backsync_map = {connection["id"]: connection["backsync"] for connection in serializer.validated_data} # bulk create connections + alert_receive_channels = instance.organization.alert_receive_channels.filter( + public_primary_key__in=backsync_map.keys() + ) AlertReceiveChannelConnection.objects.bulk_create( [ AlertReceiveChannelConnection( @@ -784,14 +787,16 @@ class AlertReceiveChannelView( connected_alert_receive_channel=alert_receive_channel, backsync=backsync_map[alert_receive_channel.public_primary_key], ) - for alert_receive_channel in instance.organization.alert_receive_channels.filter( - public_primary_key__in=backsync_map.keys() - ) + for alert_receive_channel in alert_receive_channels ], ignore_conflicts=True, batch_size=5000, ) + # add connected integrations to filtered_integrations + for webhook in instance.webhooks.filter(is_from_connected_integration=True): + webhook.filtered_integrations.add(*alert_receive_channels) + return Response(AlertReceiveChannelConnectionSerializer(instance).data, status=status.HTTP_201_CREATED) @extend_schema( @@ -829,6 +834,11 @@ class AlertReceiveChannelView( raise NotFound connection.delete() + + # remove the connected integration from filtered_integrations + for webhook in instance.webhooks.filter(is_from_connected_integration=True): + webhook.filtered_integrations.remove(connection.connected_alert_receive_channel) + return Response(status=status.HTTP_204_NO_CONTENT) @extend_schema(responses={status.HTTP_200_OK: None}) diff --git a/engine/apps/webhooks/models/webhook.py b/engine/apps/webhooks/models/webhook.py index 1e9fd2b7..7b5551ed 100644 --- a/engine/apps/webhooks/models/webhook.py +++ b/engine/apps/webhooks/models/webhook.py @@ -9,6 +9,8 @@ from django.conf import settings from django.core.validators import MinLengthValidator from django.db import models from django.db.models import F +from django.db.models.signals import post_save +from django.dispatch import receiver from django.utils import timezone from mirage import fields as mirage_fields from requests.auth import HTTPBasicAuth @@ -337,3 +339,15 @@ class WebhookResponse(models.Model): def json(self): if self.content: return json.loads(self.content) + + +@receiver(post_save, sender=WebhookResponse) +def webhook_response_post_save(sender, instance, created, *args, **kwargs): + if not created: + return + + source_alert_receive_channel = instance.webhook.filtered_integrations.filter( + connected_alert_receive_channels__isnull=False + ).first() # TODO: is it possible to have more than one? + if source_alert_receive_channel and hasattr(source_alert_receive_channel.config, "on_webhook_response_created"): + source_alert_receive_channel.config.on_webhook_response_created(instance, source_alert_receive_channel) diff --git a/engine/apps/webhooks/tasks/trigger_webhook.py b/engine/apps/webhooks/tasks/trigger_webhook.py index 756107de..264c4319 100644 --- a/engine/apps/webhooks/tasks/trigger_webhook.py +++ b/engine/apps/webhooks/tasks/trigger_webhook.py @@ -120,9 +120,7 @@ def _build_payload( response_data = r.content responses_data[r.webhook.public_primary_key] = response_data - data = serialize_event(event, alert_group, user, webhook, responses_data) - - return data + return serialize_event(event, alert_group, user, webhook, responses_data) def mask_authorization_header( diff --git a/engine/apps/webhooks/tests/test_trigger_webhook.py b/engine/apps/webhooks/tests/test_trigger_webhook.py index 38e7e332..b6d370cb 100644 --- a/engine/apps/webhooks/tests/test_trigger_webhook.py +++ b/engine/apps/webhooks/tests/test_trigger_webhook.py @@ -6,7 +6,7 @@ import pytest import requests from django.utils import timezone -from apps.alerts.models import AlertGroupLogRecord, EscalationPolicy +from apps.alerts.models import AlertGroupExternalID, AlertGroupLogRecord, EscalationPolicy from apps.base.models import UserNotificationPolicyLogRecord from apps.public_api.serializers import IncidentSerializer from apps.webhooks.models import Webhook @@ -689,3 +689,65 @@ def test_manually_retried_exceptions( mock_requests.post.assert_called_once_with("https://test/", timeout=TIMEOUT, headers={}) spy_execute_webhook.apply_async.assert_not_called() + + +@patch("apps.webhooks.models.webhook.requests.post", return_value=MockResponse()) +@patch("apps.webhooks.utils.socket.gethostbyname", return_value="8.8.8.8") +@pytest.mark.django_db +def test_execute_webhook_integration_config( + _, + mock_requests_post, + make_organization, + make_user_for_organization, + make_alert_receive_channel, + make_alert_receive_channel_connection, + make_alert_group, + make_user_notification_policy_log_record, + make_custom_webhook, +): + organization = make_organization() + user = make_user_for_organization(organization) + + # create connected integrations + source_alert_receive_channel = make_alert_receive_channel(organization) + alert_receive_channel = make_alert_receive_channel(organization) + make_alert_receive_channel_connection(source_alert_receive_channel, alert_receive_channel) + + alert_group = make_alert_group(alert_receive_channel) + webhook = make_custom_webhook( + organization=organization, + url="https://something/{{ external_id }}", + http_method="POST", + trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED, + forward_all=True, + ) + webhook.filtered_integrations.set([source_alert_receive_channel, alert_receive_channel]) + + # create external ID entry + AlertGroupExternalID.objects.create( + source_alert_receive_channel=source_alert_receive_channel, alert_group=alert_group, value="test123" + ) + + with patch.object( + source_alert_receive_channel.config, + "additional_webhook_data", + create=True, + return_value={"additional_field": "additional_value"}, + ) as mock_additional_webhook_data: + with patch.object( + source_alert_receive_channel.config, "on_webhook_response_created", create=True + ) as mock_on_webhook_response_created: + execute_webhook(webhook.pk, alert_group.pk, user.pk, None, trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED) + + assert mock_requests_post.called + + # check external ID + assert mock_requests_post.call_args[0][0] == "https://something/test123" + assert mock_requests_post.call_args[1]["json"]["external_id"] == "test123" + + # check additional webhook data + assert mock_requests_post.call_args[1]["json"]["additional_field"] == "additional_value" + mock_additional_webhook_data.assert_called_once_with(source_alert_receive_channel) + + # check on_webhook_response_created is called + mock_on_webhook_response_created.assert_called_once_with(webhook.responses.all()[0], source_alert_receive_channel) diff --git a/engine/apps/webhooks/utils.py b/engine/apps/webhooks/utils.py index 016ce181..74ea647d 100644 --- a/engine/apps/webhooks/utils.py +++ b/engine/apps/webhooks/utils.py @@ -150,6 +150,7 @@ def _extract_users_from_escalation_snapshot(escalation_snapshot): def serialize_event(event, alert_group, user, webhook, responses=None): + from apps.alerts.models import AlertGroupExternalID from apps.public_api.serializers import IncidentSerializer alert_payload = alert_group.alerts.first() @@ -184,4 +185,19 @@ def serialize_event(event, alert_group, user, webhook, responses=None): data["webhook"] = {"id": webhook.public_primary_key, "name": webhook.name, "labels": get_labels_dict(webhook)} data["integration"]["labels"] = get_labels_dict(alert_group.channel) data["alert_group"]["labels"] = get_alert_group_labels_dict(alert_group) + + # Add additional webhook data if the integration has it + source_alert_receive_channel = webhook.filtered_integrations.filter( + connected_alert_receive_channels__isnull=False + ).first() # TODO: is it possible to have more than one? + if source_alert_receive_channel and hasattr(source_alert_receive_channel.config, "additional_webhook_data"): + data.update(source_alert_receive_channel.config.additional_webhook_data(source_alert_receive_channel)) + + # Add external ID (e.g. ServiceNow incident ID) to webhook data + if source_alert_receive_channel: + external_id = AlertGroupExternalID.objects.filter( + source_alert_receive_channel=source_alert_receive_channel, alert_group=alert_group + ).first() + data["external_id"] = external_id.value if external_id else None + return data diff --git a/grafana-plugin/src/pages/integration/OutgoingTab/OutgoingWebhookFormFields.tsx b/grafana-plugin/src/pages/integration/OutgoingTab/OutgoingWebhookFormFields.tsx index 5eaddaf4..fa2172fc 100644 --- a/grafana-plugin/src/pages/integration/OutgoingTab/OutgoingWebhookFormFields.tsx +++ b/grafana-plugin/src/pages/integration/OutgoingTab/OutgoingWebhookFormFields.tsx @@ -20,7 +20,6 @@ import { MonacoEditor } from 'components/MonacoEditor/MonacoEditor'; import { MONACO_READONLY_CONFIG } from 'components/MonacoEditor/MonacoEditor.config'; import { WebhooksTemplateEditor } from 'containers/WebhooksTemplateEditor/WebhooksTemplateEditor'; import { HTTP_METHOD_OPTIONS, WEBHOOK_TRIGGGER_TYPE_OPTIONS } from 'models/outgoing_webhook/outgoing_webhook.types'; -import { VALID_URL_PATTERN } from 'utils/string'; import { getStyles } from './OutgoingTab.styles'; import { OutgoingTabFormValues } from './OutgoingTab.types'; @@ -101,7 +100,6 @@ export const OutgoingWebhookFormFields: FC = ({