SNOW external ID (#4076)

# What this PR does

- Adds a new model `AlertGroupExternalID` to keep track of incident IDs
in external systems
- Adds calls to integration config specific functions on alert group
creation and webhook response

## Which issue(s) this PR closes

Related to https://github.com/grafana/oncall-private/issues/2541

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] Added the relevant release notes label (see labels prefixed w/
`release:`). These labels dictate how your PR will
    show up in the autogenerated release notes.
This commit is contained in:
Vadim Stepanov 2024-03-20 10:54:27 +00:00 committed by GitHub
parent 5bcb438012
commit 5074a16861
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 210 additions and 10 deletions

View file

@ -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

View file

@ -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')},
},
),
]

View file

@ -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

View file

@ -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"]),
]

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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})

View file

@ -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)

View file

@ -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(

View file

@ -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)

View file

@ -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

View file

@ -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<OutgoingWebhookFormFieldsProps> = ({
<Input
{...register('url', {
required: 'URL is required',
pattern: { value: VALID_URL_PATTERN, message: 'URL is invalid' },
})}
/>
</Field>