Merge pull request #1773 from grafana/dev

v1.2.12
This commit is contained in:
Joey Orlando 2023-04-18 13:13:07 +02:00 committed by GitHub
commit eccf40e004
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 677 additions and 454 deletions

1
.gitignore vendored
View file

@ -3,6 +3,7 @@ venv
.python-version
.vscode
*.http
.idea
.DS_Store
.env

View file

@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v1.2.12 (2023-04-18)
### Changed
- Move `alerts_alertgroup.is_restricted` column to `alerts_alertreceivechannel.restricted_at` by @joeyorlando ([#1770](https://github.com/grafana/oncall/pull/1770))
### Added
- Add new field description_short to private api ([#1698](https://github.com/grafana/oncall/pull/1698))
- Added preview and migration API endpoints for route migration from regex into jinja2 ([1715](https://github.com/grafana/oncall/pull/1715))
- Helm chart: add the option to use a helm hook for the migration job ([1386](https://github.com/grafana/oncall/pull/1386))
- Add endpoints to start and stop maintenance in alert receive channel private api ([1755](https://github.com/grafana/oncall/pull/1755))
- Send demo alert with dynamic payload and get demo payload example on private api ([1700](https://github.com/grafana/oncall/pull/1700))
- Add is_default fields to templates, remove WritableSerialiserMethodField ([1759](https://github.com/grafana/oncall/pull/1759))
- Allow use of dynamic payloads in alert receive channels preview template in private api ([1756](https://github.com/grafana/oncall/pull/1756))
## v1.2.11 (2023-04-14)
### Added
@ -41,7 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Save selected teams filter in local storage ([1611](https://github.com/grafana/oncall/issues/1611))
- Save selected teams filter in local storage ([#1611](https://github.com/grafana/oncall/issues/1611))
### Changed

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.18 on 2023-04-17 01:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('alerts', '0011_auto_20230329_1617'),
]
operations = [
migrations.AddField(
model_name='alertreceivechannel',
name='description_short',
field=models.CharField(default=None, max_length=250, null=True),
),
]

View file

@ -10,11 +10,6 @@ class Migration(migrations.Migration):
]
operations = [
# migrations.AddField(
# model_name='alertgroup',
# name='is_restricted',
# field=models.BooleanField(default=False, null=True),
# ),
migrations.AlterField(
model_name='alertgrouplogrecord',
name='type',

View file

@ -0,0 +1,14 @@
# Generated by Django 3.2.18 on 2023-04-18 03:36
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('alerts', '0012_alertreceivechannel_description_short'),
('alerts', '0012_auto_20230406_1010'),
]
operations = [
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.18 on 2023-04-18 05:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('alerts', '0013_merge_20230418_0336'),
]
operations = [
migrations.AddField(
model_name='alertreceivechannel',
name='restricted_at',
field=models.DateTimeField(default=None, null=True),
),
]

View file

@ -352,11 +352,12 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
# https://code.djangoproject.com/ticket/28545
is_open_for_grouping = models.BooleanField(default=None, null=True, blank=True)
# is_restricted = models.BooleanField(default=False, null=True)
@property
def is_restricted(self):
return False
integration_restricted_at = self.channel.restricted_at
if integration_restricted_at is None:
return False
return self.started_at >= integration_restricted_at
@staticmethod
def get_silenced_state_filter():

View file

@ -145,6 +145,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
smile_code = models.TextField(default=":slightly_smiling_face:")
verbal_name = models.CharField(max_length=150, null=True, default=None)
description_short = models.CharField(max_length=250, null=True, default=None)
integration_slack_channel_id = models.CharField(max_length=150, null=True, default=None)
@ -186,6 +187,8 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
rate_limited_in_slack_at = models.DateTimeField(null=True, default=None)
rate_limit_message_task_id = models.CharField(max_length=100, null=True, default=None)
restricted_at = models.DateTimeField(null=True, default=None)
class Meta:
constraints = [
models.UniqueConstraint(
@ -501,19 +504,26 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
return getattr(heartbeat, self.INTEGRATIONS_TO_REVERSE_URL_MAP[self.integration], None)
# Demo alerts
def send_demo_alert(self, force_route_id=None):
def send_demo_alert(self, force_route_id=None, payload=None):
logger.info(f"send_demo_alert integration={self.pk} force_route_id={force_route_id}")
if payload is None:
payload = self.config.example_payload
if self.is_demo_alert_enabled:
if self.has_alertmanager_payload_structure:
for alert in self.config.example_payload.get("alerts", []):
create_alertmanager_alerts.apply_async(
[],
{
"alert_receive_channel_pk": self.pk,
"alert": alert,
"is_demo": True,
"force_route_id": force_route_id,
},
if (alerts := payload.get("alerts", None)) and type(alerts) == list and len(alerts):
for alert in alerts:
create_alertmanager_alerts.apply_async(
[],
{
"alert_receive_channel_pk": self.pk,
"alert": alert,
"is_demo": True,
"force_route_id": force_route_id,
},
)
else:
raise UnableToSendDemoAlert(
"Unable to send demo alert as payload has no 'alerts' key, it is not array, or it is empty."
)
else:
create_alert.apply_async(
@ -525,7 +535,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
"link_to_upstream_details": None,
"alert_receive_channel_pk": self.pk,
"integration_unique_data": None,
"raw_request_data": self.config.example_payload,
"raw_request_data": payload,
"is_demo": True,
"force_route_id": force_route_id,
},

View file

@ -94,6 +94,8 @@ class ChannelFilter(OrderedModel):
@classmethod
def select_filter(cls, alert_receive_channel, raw_request_data, force_route_id=None):
# Try to find force route first if force_route_id is given
# Force route was used to send demo alerts to specific route.
# It is deprecated and may be used by older versions of the plugins
if force_route_id is not None:
logger.info(
f"start select_filter with force_route_id={force_route_id} alert_receive_channel={alert_receive_channel.pk}."
@ -164,6 +166,7 @@ class ChannelFilter(OrderedModel):
raise Exception("Unknown filtering term")
def send_demo_alert(self):
"""Deprecated. May be used in the older versions of the plugin"""
integration = self.alert_receive_channel
integration.send_demo_alert(force_route_id=self.pk)

View file

@ -90,14 +90,25 @@ def test_get_default_template_attribute_fallback_to_web(make_organization, make_
@mock.patch("apps.integrations.tasks.create_alert.apply_async", return_value=None)
@pytest.mark.django_db
def test_send_demo_alert(mocked_create_alert, make_organization, make_alert_receive_channel):
@pytest.mark.parametrize(
"payload",
[
None,
{"foo": "bar"},
],
)
def test_send_demo_alert(mocked_create_alert, make_organization, make_alert_receive_channel, payload):
organization = make_organization()
alert_receive_channel = make_alert_receive_channel(
organization, integration=AlertReceiveChannel.INTEGRATION_WEBHOOK
)
alert_receive_channel.send_demo_alert()
alert_receive_channel.send_demo_alert(payload=payload)
assert mocked_create_alert.called
assert mocked_create_alert.call_args.args[1]["is_demo"]
assert (
mocked_create_alert.call_args.args[1]["raw_request_data"] == payload
or alert_receive_channel.config.example_payload
)
assert mocked_create_alert.call_args.args[1]["force_route_id"] is None
@ -111,14 +122,26 @@ def test_send_demo_alert(mocked_create_alert, make_organization, make_alert_rece
AlertReceiveChannel.INTEGRATION_GRAFANA_ALERTING,
],
)
@pytest.mark.parametrize(
"payload",
[
None,
{"alerts": [{"foo": "bar"}]},
],
)
def test_send_demo_alert_alertmanager_payload_shape(
mocked_create_alert, make_organization, make_alert_receive_channel, integration
mocked_create_alert, make_organization, make_alert_receive_channel, integration, payload
):
organization = make_organization()
alert_receive_channel = make_alert_receive_channel(organization, integration=integration)
alert_receive_channel.send_demo_alert()
alert_receive_channel.send_demo_alert(payload=payload)
assert mocked_create_alert.called
assert mocked_create_alert.call_args.args[1]["is_demo"]
assert (
mocked_create_alert.call_args.args[1]["alert"] == payload["alerts"][0]
if payload
else alert_receive_channel.config.example_payload["alerts"][0]
)
assert mocked_create_alert.call_args.args[1]["force_route_id"] is None

View file

@ -1,22 +1,19 @@
from collections import OrderedDict
from collections.abc import Mapping
from django.apps import apps
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ValidationError as DjangoValidationError
from django.template.loader import render_to_string
from django.utils import timezone
from jinja2 import TemplateSyntaxError
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.fields import SerializerMethodField, SkipField, get_error_detail, set_value
from rest_framework.settings import api_settings
from rest_framework.fields import SerializerMethodField, set_value
from apps.alerts.grafana_alerting_sync_manager.grafana_alerting_sync import GrafanaAlertingSyncManager
from apps.alerts.models import AlertReceiveChannel
from apps.base.messaging import get_messaging_backends
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField, WritableSerializerMethodField
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.mixins import APPEARANCE_TEMPLATE_NAMES, EagerLoadingMixin
from common.api_helpers.utils import CurrentTeamDefault
@ -50,6 +47,8 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
maintenance_till = serializers.ReadOnlyField(source="till_maintenance_timestamp")
heartbeat = serializers.SerializerMethodField()
allow_delete = serializers.SerializerMethodField()
description_short = serializers.CharField(max_length=250, required=False)
demo_alert_payload = serializers.SerializerMethodField()
# integration heartbeat is in PREFETCH_RELATED not by mistake.
# With using of select_related ORM builds strange join
@ -62,6 +61,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
fields = [
"id",
"description",
"description_short",
"integration",
"smile_code",
"verbal_name",
@ -82,6 +82,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
"heartbeat",
"is_available_for_integration_heartbeat",
"allow_delete",
"demo_alert_payload",
]
read_only_fields = [
"created_at",
@ -92,6 +93,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
"instructions",
"demo_alert_enabled",
"maintenance_mode",
"demo_alert_payload",
]
extra_kwargs = {"integration": {"required": True}}
@ -153,6 +155,14 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
def get_alert_groups_count(self, obj):
return 0
def get_demo_alert_payload(self, obj):
if obj.is_demo_alert_enabled:
try:
return obj.config.example_payload
except AttributeError:
return "{}"
return None
class AlertReceiveChannelUpdateSerializer(AlertReceiveChannelSerializer):
class Meta(AlertReceiveChannelSerializer.Meta):
@ -191,96 +201,23 @@ class FilterAlertReceiveChannelSerializer(serializers.ModelSerializer):
class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.ModelSerializer):
id = serializers.CharField(read_only=True, source="public_primary_key")
slack_title_template = WritableSerializerMethodField(
allow_null=True,
deserializer_field=serializers.CharField(),
validators=[valid_jinja_template_for_serializer_method_field],
required=False,
)
slack_message_template = WritableSerializerMethodField(
allow_null=True,
deserializer_field=serializers.CharField(),
validators=[valid_jinja_template_for_serializer_method_field],
required=False,
)
slack_image_url_template = WritableSerializerMethodField(
allow_null=True,
deserializer_field=serializers.CharField(),
validators=[valid_jinja_template_for_serializer_method_field],
required=False,
)
web_title_template = WritableSerializerMethodField(
allow_null=True,
deserializer_field=serializers.CharField(),
validators=[valid_jinja_template_for_serializer_method_field],
required=False,
)
web_message_template = WritableSerializerMethodField(
allow_null=True,
deserializer_field=serializers.CharField(),
validators=[valid_jinja_template_for_serializer_method_field],
required=False,
)
web_image_url_template = WritableSerializerMethodField(
allow_null=True,
deserializer_field=serializers.CharField(),
validators=[valid_jinja_template_for_serializer_method_field],
required=False,
)
sms_title_template = WritableSerializerMethodField(
allow_null=True,
deserializer_field=serializers.CharField(),
validators=[valid_jinja_template_for_serializer_method_field],
required=False,
)
phone_call_title_template = WritableSerializerMethodField(
allow_null=True,
deserializer_field=serializers.CharField(),
validators=[valid_jinja_template_for_serializer_method_field],
required=False,
)
telegram_title_template = WritableSerializerMethodField(
allow_null=True,
deserializer_field=serializers.CharField(),
validators=[valid_jinja_template_for_serializer_method_field],
required=False,
)
telegram_message_template = WritableSerializerMethodField(
allow_null=True,
deserializer_field=serializers.CharField(),
validators=[valid_jinja_template_for_serializer_method_field],
required=False,
)
telegram_image_url_template = WritableSerializerMethodField(
allow_null=True,
deserializer_field=serializers.CharField(),
validators=[valid_jinja_template_for_serializer_method_field],
required=False,
)
source_link_template = WritableSerializerMethodField(
allow_null=True,
deserializer_field=serializers.CharField(),
validators=[valid_jinja_template_for_serializer_method_field],
required=False,
)
grouping_id_template = WritableSerializerMethodField(
allow_null=True,
deserializer_field=serializers.CharField(),
validators=[valid_jinja_template_for_serializer_method_field],
required=False,
)
acknowledge_condition_template = WritableSerializerMethodField(
allow_null=True,
deserializer_field=serializers.CharField(),
validators=[valid_jinja_template_for_serializer_method_field],
required=False,
)
resolve_condition_template = WritableSerializerMethodField(
allow_null=True,
deserializer_field=serializers.CharField(),
validators=[valid_jinja_template_for_serializer_method_field],
required=False,
)
CORE_TEMPLATE_NAMES = [
"slack_title_template",
"slack_message_template",
"slack_image_url_template",
"web_title_template",
"web_message_template",
"web_image_url_template",
"telegram_title_template",
"telegram_message_template",
"telegram_image_url_template",
"sms_title_template",
"phone_call_title_template",
"source_link_template",
"grouping_id_template",
"resolve_condition_template",
"acknowledge_condition_template",
]
payload_example = SerializerMethodField()
@ -289,207 +226,10 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
fields = [
"id",
"verbal_name",
"slack_title_template",
"slack_message_template",
"slack_image_url_template",
"sms_title_template",
"phone_call_title_template",
"web_title_template",
"web_message_template",
"web_image_url_template",
"telegram_title_template",
"telegram_message_template",
"telegram_image_url_template",
"source_link_template",
"grouping_id_template",
"resolve_condition_template",
"payload_example",
"acknowledge_condition_template",
]
extra_kwargs = {"integration": {"required": True}}
# MethodFields are used instead of relevant properties because of properties hit db on each instance in queryset
def get_slack_title_template(self, obj):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SLACK_TITLE_TEMPLATE[obj.integration]
return obj.slack_title_template or default_template
def set_slack_title_template(self, value):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SLACK_TITLE_TEMPLATE[self.instance.integration]
if default_template is None or default_template.strip() != value.strip():
self.instance.slack_title_template = value.strip()
elif default_template is not None and default_template.strip() == value.strip():
self.instance.slack_title_template = None
def get_slack_message_template(self, obj):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SLACK_MESSAGE_TEMPLATE[obj.integration]
return obj.slack_message_template or default_template
def set_slack_message_template(self, value):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SLACK_MESSAGE_TEMPLATE[self.instance.integration]
if default_template is None or default_template.strip() != value.strip():
self.instance.slack_message_template = value.strip()
elif default_template is not None and default_template.strip() == value.strip():
self.instance.slack_message_template = None
def get_slack_image_url_template(self, obj):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SLACK_IMAGE_URL_TEMPLATE[obj.integration]
return obj.slack_image_url_template or default_template
def set_slack_image_url_template(self, value):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SLACK_IMAGE_URL_TEMPLATE[
self.instance.integration
]
if default_template is None or default_template.strip() != value.strip():
self.instance.slack_image_url_template = value.strip()
elif default_template is not None and default_template.strip() == value.strip():
self.instance.slack_image_url_template = None
def get_sms_title_template(self, obj):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SMS_TITLE_TEMPLATE[obj.integration]
return obj.sms_title_template or default_template
def set_sms_title_template(self, value):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SMS_TITLE_TEMPLATE[self.instance.integration]
if default_template is None or default_template.strip() != value.strip():
self.instance.sms_title_template = value.strip()
elif default_template is not None and default_template.strip() == value.strip():
self.instance.sms_title_template = None
def get_phone_call_title_template(self, obj):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_PHONE_CALL_TITLE_TEMPLATE[obj.integration]
return obj.phone_call_title_template or default_template
def set_phone_call_title_template(self, value):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_PHONE_CALL_TITLE_TEMPLATE[
self.instance.integration
]
if default_template is None or default_template.strip() != value.strip():
self.instance.phone_call_title_template = value.strip()
elif default_template is not None and default_template.strip() == value.strip():
self.instance.phone_call_title_template = None
def get_web_title_template(self, obj):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_WEB_TITLE_TEMPLATE[obj.integration]
return obj.web_title_template or default_template
def set_web_title_template(self, value):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_WEB_TITLE_TEMPLATE[self.instance.integration]
if default_template is None or default_template.strip() != value.strip():
self.instance.web_title_template = value.strip()
elif default_template is not None and default_template.strip() == value.strip():
self.instance.web_title_template = None
self.instance.web_templates_modified_at = timezone.now()
def get_web_message_template(self, obj):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_WEB_MESSAGE_TEMPLATE[obj.integration]
return obj.web_message_template or default_template
def set_web_message_template(self, value):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_WEB_MESSAGE_TEMPLATE[self.instance.integration]
if default_template is None or default_template.strip() != value.strip():
self.instance.web_message_template = value.strip()
elif default_template is not None and default_template.strip() == value.strip():
self.instance.web_message_template = None
self.instance.web_templates_modified_at = timezone.now()
def get_web_image_url_template(self, obj):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_WEB_IMAGE_URL_TEMPLATE[obj.integration]
return obj.web_image_url_template or default_template
def set_web_image_url_template(self, value):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_WEB_IMAGE_URL_TEMPLATE[self.instance.integration]
if default_template is None or default_template.strip() != value.strip():
self.instance.web_image_url_template = value.strip()
elif default_template is not None and default_template.strip() == value.strip():
self.instance.web_image_url_template = None
self.instance.web_templates_modified_at = timezone.now()
def get_telegram_title_template(self, obj):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_TELEGRAM_TITLE_TEMPLATE[obj.integration]
return obj.telegram_title_template or default_template
def set_telegram_title_template(self, value):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_TELEGRAM_TITLE_TEMPLATE[self.instance.integration]
if default_template is None or default_template.strip() != value.strip():
self.instance.telegram_title_template = value.strip()
elif default_template is not None and default_template.strip() == value.strip():
self.instance.telegram_title_template = None
def get_telegram_message_template(self, obj):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_TELEGRAM_MESSAGE_TEMPLATE[obj.integration]
return obj.telegram_message_template or default_template
def set_telegram_message_template(self, value):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_TELEGRAM_MESSAGE_TEMPLATE[
self.instance.integration
]
if default_template is None or default_template.strip() != value.strip():
self.instance.telegram_message_template = value.strip()
elif default_template is not None and default_template.strip() == value.strip():
self.instance.telegram_message_template = None
def get_telegram_image_url_template(self, obj):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_TELEGRAM_IMAGE_URL_TEMPLATE[obj.integration]
return obj.telegram_image_url_template or default_template
def set_telegram_image_url_template(self, value):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_TELEGRAM_IMAGE_URL_TEMPLATE[
self.instance.integration
]
if default_template is None or default_template.strip() != value.strip():
self.instance.telegram_image_url_template = value.strip()
elif default_template is not None and default_template.strip() == value.strip():
self.instance.telegram_image_url_template = None
def get_source_link_template(self, obj):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SOURCE_LINK_TEMPLATE[obj.integration]
return obj.source_link_template or default_template
def set_source_link_template(self, value):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SOURCE_LINK_TEMPLATE[self.instance.integration]
if default_template is None or default_template.strip() != value.strip():
self.instance.source_link_template = value.strip()
elif default_template is not None and default_template.strip() == value.strip():
self.instance.source_link_template = None
def get_grouping_id_template(self, obj):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_GROUPING_ID_TEMPLATE[obj.integration]
return obj.grouping_id_template or default_template
def set_grouping_id_template(self, value):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_GROUPING_ID_TEMPLATE[self.instance.integration]
if default_template is None or default_template.strip() != value.strip():
self.instance.grouping_id_template = value.strip()
elif default_template is not None and default_template.strip() == value.strip():
self.instance.grouping_id_template = None
def get_acknowledge_condition_template(self, obj):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_ACKNOWLEDGE_CONDITION_TEMPLATE[obj.integration]
return obj.acknowledge_condition_template or default_template
def set_acknowledge_condition_template(self, value):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_ACKNOWLEDGE_CONDITION_TEMPLATE[
self.instance.integration
]
if default_template is None or default_template.strip() != value.strip():
self.instance.acknowledge_condition_template = value.strip()
elif default_template is not None and default_template.strip() == value.strip():
self.instance.acknowledge_condition_template = None
def get_resolve_condition_template(self, obj):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_RESOLVE_CONDITION_TEMPLATE[obj.integration]
return obj.resolve_condition_template or default_template
def set_resolve_condition_template(self, value):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_RESOLVE_CONDITION_TEMPLATE[
self.instance.integration
]
if default_template is None or default_template.strip() != value.strip():
self.instance.resolve_condition_template = value.strip()
elif default_template is not None and default_template.strip() == value.strip():
self.instance.resolve_condition_template = None
def get_payload_example(self, obj):
AlertGroup = apps.get_model("alerts", "AlertGroup")
if "alert_group_id" in self.context["request"].query_params:
@ -511,33 +251,15 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
"""
Dict of native values <- Dict of primitive datatypes.
"""
if not isinstance(data, Mapping):
message = self.error_messages["invalid"].format(datatype=type(data).__name__)
raise ValidationError({api_settings.NON_FIELD_ERRORS_KEY: [message]}, code="invalid")
# First validate and save data from serializer fields
ret = super().to_internal_value(data)
ret = OrderedDict()
# Separately validate and save template fields we generate dynamically
errors = OrderedDict()
fields = self._writable_fields
for field in fields:
validate_method = getattr(self, "validate_" + field.field_name, None)
primitive_value = field.get_value(data)
try:
validated_value = field.run_validation(primitive_value)
if validate_method is not None:
validated_value = validate_method(validated_value)
except ValidationError as exc:
errors[field.field_name] = exc.detail
except DjangoValidationError as exc:
errors[field.field_name] = get_error_detail(exc)
except SkipField:
pass
else:
# Line because of which method is overriden
if validated_value is None and isinstance(field, WritableSerializerMethodField):
set_value(ret, [field.field_name], validated_value)
else:
set_value(ret, field.source_attrs, validated_value)
# handle updates for core templates
core_template_errors = self._handle_core_template_updates(data, ret)
errors.update(core_template_errors)
# handle updates for messaging backend templates
messaging_backend_errors = self._handle_messaging_backend_updates(data, ret)
@ -545,7 +267,6 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
if errors:
raise ValidationError(errors)
return ret
def _handle_messaging_backend_updates(self, data, ret):
@ -580,10 +301,33 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
return errors
def _handle_core_template_updates(self, data, ret):
"""Update core templates if needed."""
errors = {}
core_template_names = self.CORE_TEMPLATE_NAMES
for field_name in core_template_names:
value = data.get(field_name)
validator = jinja_template_env.from_string
if value is not None:
try:
if value:
validator(value)
except TemplateSyntaxError:
errors[field_name] = "invalid template"
except DjangoValidationError:
errors[field_name] = "invalid URL"
set_value(ret, [field_name], value)
return errors
def to_representation(self, obj):
ret = super().to_representation(obj)
ret = self._get_templates_to_show(ret)
core_templates = self._get_core_templates(obj)
ret.update(core_templates)
# include messaging backend templates
additional_templates = self._get_messaging_backend_templates(obj)
ret.update(additional_templates)
@ -621,10 +365,26 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
continue
for field in backend.template_fields:
value = None
is_default = False
if obj.messaging_backends_templates:
value = obj.messaging_backends_templates.get(backend_id, {}).get(field)
if not value:
value = obj.get_default_template_attribute(backend_id, field)
is_default = True
field_name = f"{backend.slug}_{field}_template"
templates[field_name] = value
templates[f"{field_name}_is_default"] = is_default
return templates
def _get_core_templates(self, obj):
core_templates = {}
core_template_names = self.CORE_TEMPLATE_NAMES
for template_name in core_template_names:
template_value = getattr(obj, template_name)
defaults = getattr(obj, f"INTEGRATION_TO_DEFAULT_{template_name.upper()}", {})
default_template_value = defaults.get(obj.integration)
core_templates[template_name] = template_value or default_template_value
core_templates[f"{template_name}_is_default"] = not bool(template_value)
return core_templates

View file

@ -26,6 +26,7 @@ class ChannelFilterSerializer(OrderedModelSerializerMixin, EagerLoadingMixin, se
queryset=TelegramToOrganizationConnector.objects, filter_field="organization", allow_null=True, required=False
)
order = serializers.IntegerField(required=False)
filtering_term_as_jinja2 = serializers.SerializerMethodField()
SELECT_RELATED = ["escalation_chain", "alert_receive_channel"]
@ -45,6 +46,7 @@ class ChannelFilterSerializer(OrderedModelSerializerMixin, EagerLoadingMixin, se
"notify_in_slack",
"notify_in_telegram",
"notification_backends",
"filtering_term_as_jinja2",
]
read_only_fields = ["created_at", "is_default"]
extra_kwargs = {"filtering_term": {"required": True, "allow_null": False}}
@ -107,6 +109,16 @@ class ChannelFilterSerializer(OrderedModelSerializerMixin, EagerLoadingMixin, se
notification_backends = updated
return notification_backends
def get_filtering_term_as_jinja2(self, obj):
"""
Returns the regex filtering term as a jinja2, for the preview before migration from regex to jinja2"""
if obj.filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_JINJA2:
return obj.filtering_term
elif obj.filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_REGEX:
# Four curly braces will result in two curly braces in the final string
# rf"..." is a raw f string, to keep original filtering_term
return rf'{{{{ payload | json_dumps | regex_search("{obj.filtering_term}") }}}}'
class ChannelFilterCreateSerializer(ChannelFilterSerializer):
alert_receive_channel = OrganizationFilteredPrimaryKeyRelatedField(queryset=AlertReceiveChannel.objects)

View file

@ -505,6 +505,44 @@ def test_alert_receive_channel_preview_template_require_notification_channel(
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
@pytest.mark.parametrize("template_name", ["title", "message", "image_url"])
@pytest.mark.parametrize("notification_channel", ["slack", "web", "telegram"])
def test_alert_receive_channel_preview_template_dynamic_payload(
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_alert_receive_channel,
template_name,
notification_channel,
make_alert_group,
make_alert,
):
organization, user, token = make_organization_and_user_with_plugin_token()
alert_receive_channel = make_alert_receive_channel(organization)
alert_group = make_alert_group(alert_receive_channel)
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
client = APIClient()
url = reverse(
"api-internal:alert_receive_channel-preview-template", kwargs={"pk": alert_receive_channel.public_primary_key}
)
data = {
"template_body": "{{ payload.foo }}",
"template_name": f"{notification_channel}_{template_name}",
"payload": {"foo": "bar"},
}
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
if notification_channel == "web" and template_name == "message":
assert response.data["preview"] == "<p>bar</p>"
else:
assert response.data["preview"] == "bar"
@pytest.mark.django_db
@pytest.mark.parametrize(
"role,expected_status",
@ -669,3 +707,68 @@ def test_get_alert_receive_channels_direct_paging_present_for_filters(
# Check direct paging integration is in the response
assert response.status_code == status.HTTP_200_OK
assert response.json()[0]["value"] == alert_receive_channel.public_primary_key
@pytest.mark.django_db
def test_start_maintenance_integration(
make_user_auth_headers,
make_organization_and_user_with_plugin_token,
make_escalation_chain,
make_alert_receive_channel,
):
organization, user, token = make_organization_and_user_with_plugin_token()
make_escalation_chain(organization)
alert_receive_channel = make_alert_receive_channel(organization)
client = APIClient()
url = reverse(
"api-internal:alert_receive_channel-start-maintenance", kwargs={"pk": alert_receive_channel.public_primary_key}
)
data = {
"mode": AlertReceiveChannel.MAINTENANCE,
"duration": AlertReceiveChannel.DURATION_ONE_HOUR.total_seconds(),
"type": "alert_receive_channel",
}
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
alert_receive_channel.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert alert_receive_channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE
assert alert_receive_channel.maintenance_duration == AlertReceiveChannel.DURATION_ONE_HOUR
assert alert_receive_channel.maintenance_uuid is not None
assert alert_receive_channel.maintenance_started_at is not None
assert alert_receive_channel.maintenance_author is not None
@pytest.mark.django_db
def test_stop_maintenance_integration(
mock_start_disable_maintenance_task,
make_user_auth_headers,
make_organization_and_user_with_plugin_token,
make_escalation_chain,
make_alert_receive_channel,
):
organization, user, token = make_organization_and_user_with_plugin_token()
make_escalation_chain(organization)
alert_receive_channel = make_alert_receive_channel(organization)
client = APIClient()
mode = AlertReceiveChannel.MAINTENANCE
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
alert_receive_channel.start_maintenance(mode, duration, user)
url = reverse(
"api-internal:alert_receive_channel-stop-maintenance", kwargs={"pk": alert_receive_channel.public_primary_key}
)
data = {
"type": "alert_receive_channel",
}
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
alert_receive_channel.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert alert_receive_channel.maintenance_mode is None
assert alert_receive_channel.maintenance_duration is None
assert alert_receive_channel.maintenance_uuid is None
assert alert_receive_channel.maintenance_started_at is None
assert alert_receive_channel.maintenance_author is None

View file

@ -376,4 +376,7 @@ def test_update_alert_receive_channel_templates(
# check if updated templates are applied
updated_templates_data = response.json()
for template_name, prev_template_value in existing_templates_data.items():
assert updated_templates_data[template_name] == template_update_func(prev_template_value)
if template_name.endswith("_is_default"):
assert updated_templates_data[template_name] is False
else:
assert updated_templates_data[template_name] == template_update_func(prev_template_value)

View file

@ -502,3 +502,67 @@ def test_channel_filter_update_invalid_notification_backends(
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == {"notification_backends": ["Invalid messaging backend"]}
assert channel_filter.notification_backends is None
@pytest.mark.django_db
@pytest.mark.parametrize(
"role,expected_status",
[
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
],
)
def test_channel_filter_convert_from_regex_to_jinja2(
make_organization_and_user_with_plugin_token,
make_alert_receive_channel,
make_channel_filter,
make_user_auth_headers,
role,
expected_status,
):
organization, user, token = make_organization_and_user_with_plugin_token(role)
alert_receive_channel = make_alert_receive_channel(organization)
make_channel_filter(alert_receive_channel, is_default=True)
# r"..." used to keep this string as raw string
regex_filtering_term = r"\".*\": \"This alert was sent by user for the demonstration purposes\""
final_filtering_term = r'{{ payload | json_dumps | regex_search("\".*\": \"This alert was sent by user for the demonstration purposes\"") }}'
payload = {"description": "This alert was sent by user for the demonstration purposes"}
regex_channel_filter = make_channel_filter(
alert_receive_channel,
filtering_term=regex_filtering_term,
is_default=False,
)
# Check if the filtering term is a regex
assert regex_channel_filter.filtering_term_type == regex_channel_filter.FILTERING_TERM_TYPE_REGEX
# Check if the alert is matched to the channel filter (route) regex
assert bool(regex_channel_filter.is_satisfying(payload)) is True
client = APIClient()
url = reverse("api-internal:channel_filter-detail", kwargs={"pk": regex_channel_filter.public_primary_key})
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
# Check if preview of the filtering term migration is correct
assert response.json()["filtering_term_as_jinja2"] == final_filtering_term
url = reverse(
"api-internal:channel_filter-convert-from-regex-to-jinja2",
kwargs={"pk": regex_channel_filter.public_primary_key},
)
response = client.post(url, **make_user_auth_headers(user, token))
# Only admins can convert from regex to jinja2
assert response.status_code == expected_status
if expected_status == status.HTTP_200_OK:
regex_channel_filter.refresh_from_db()
# Regex is now converted to jinja2
jinja2_channel_filter = regex_channel_filter
# Check if the filtering term is a jinja2, and if it is correct
assert jinja2_channel_filter.filtering_term_type == jinja2_channel_filter.FILTERING_TERM_TYPE_JINJA2
assert jinja2_channel_filter.filtering_term == final_filtering_term
# Check if the same alert is matched to the channel filter (route) new jinja2
assert bool(jinja2_channel_filter.is_satisfying(payload)) is True

View file

@ -657,5 +657,5 @@ class AlertGroupView(
)
# This method is required for PreviewTemplateMixin
def get_alert_to_template(self):
def get_alert_to_template(self, payload=None):
return self.get_object().alerts.first()

View file

@ -8,7 +8,8 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.alerts.models import AlertReceiveChannel
from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel
from apps.alerts.models.maintainable_object import MaintainableObject
from apps.api.permissions import RBACPermission
from apps.api.serializers.alert_receive_channel import (
AlertReceiveChannelSerializer,
@ -21,12 +22,13 @@ from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, TeamModelMultipleChoiceFilter
from common.api_helpers.mixins import (
FilterSerializerMixin,
PreviewTemplateException,
PreviewTemplateMixin,
PublicPrimaryKeyMixin,
TeamFilteringMixin,
UpdateSerializerMixin,
)
from common.exceptions import TeamCanNotBeChangedError, UnableToSendDemoAlert
from common.exceptions import MaintenanceCouldNotBeStartedError, TeamCanNotBeChangedError, UnableToSendDemoAlert
from common.insight_log import EntityEvent, write_resource_insight_log
@ -95,6 +97,8 @@ class AlertReceiveChannelView(
"destroy": [RBACPermission.Permissions.INTEGRATIONS_WRITE],
"change_team": [RBACPermission.Permissions.INTEGRATIONS_WRITE],
"filters": [RBACPermission.Permissions.INTEGRATIONS_READ],
"start_maintenance": [RBACPermission.Permissions.INTEGRATIONS_WRITE],
"stop_maintenance": [RBACPermission.Permissions.INTEGRATIONS_WRITE],
}
def create(self, request, *args, **kwargs):
@ -149,9 +153,19 @@ class AlertReceiveChannelView(
@action(detail=True, methods=["post"], throttle_classes=[DemoAlertThrottler])
def send_demo_alert(self, request, pk):
instance = AlertReceiveChannel.objects.get(public_primary_key=pk)
alert_receive_channel = AlertReceiveChannel.objects.get(public_primary_key=pk)
demo_alert_payload = request.data.get("demo_alert_payload", None)
if not demo_alert_payload:
# If no payload provided, use the demo payload for backword compatibility
payload = alert_receive_channel.config.example_payload
else:
if type(demo_alert_payload) != dict:
raise BadRequest(detail="Payload for demo alert must be a valid json object")
payload = demo_alert_payload
try:
instance.send_demo_alert()
alert_receive_channel.send_demo_alert(payload=payload)
except UnableToSendDemoAlert as e:
raise BadRequest(detail=str(e))
return Response(status=status.HTTP_200_OK)
@ -215,9 +229,16 @@ class AlertReceiveChannelView(
return Response(response)
# This method is required for PreviewTemplateMixin
def get_alert_to_template(self):
def get_alert_to_template(self, payload=None):
try:
return self.get_object().alert_groups.last().alerts.first()
if payload is None:
return self.get_object().alert_groups.last().alerts.first()
else:
if type(payload) != dict:
raise PreviewTemplateException("Payload must be a valid json object")
# Build Alert and AlertGroup objects to pass to templater without saving them to db
alert_group_to_template = AlertGroup(channel=self.get_object())
return Alert(raw_request_data=payload, group=alert_group_to_template)
except AttributeError:
return None
@ -239,3 +260,40 @@ class AlertReceiveChannelView(
filter_options = list(filter(lambda f: filter_name in f["name"], filter_options))
return Response(filter_options)
@action(detail=True, methods=["post"])
def start_maintenance(self, request, pk):
instance = self.get_queryset(eager=False).get(public_primary_key=pk)
mode = request.data.get("mode", None)
duration = request.data.get("duration", None)
try:
mode = int(mode)
except (ValueError, TypeError):
raise BadRequest(detail={"mode": ["Invalid mode"]})
if mode not in [MaintainableObject.DEBUG_MAINTENANCE, MaintainableObject.MAINTENANCE]:
raise BadRequest(detail={"mode": ["Unknown mode"]})
try:
duration = int(duration)
except (ValueError, TypeError):
raise BadRequest(detail={"duration": ["Invalid duration"]})
if duration not in MaintainableObject.maintenance_duration_options_in_seconds():
raise BadRequest(detail={"mode": ["Unknown duration"]})
try:
instance.start_maintenance(mode, duration, request.user)
except MaintenanceCouldNotBeStartedError as e:
if type(instance) == AlertReceiveChannel:
detail = {"alert_receive_channel_id": ["Already on maintenance"]}
else:
detail = str(e)
raise BadRequest(detail=detail)
return Response(status=status.HTTP_200_OK)
@action(detail=True, methods=["post"])
def stop_maintenance(self, request, pk):
instance = self.get_queryset(eager=False).get(public_primary_key=pk)
user = request.user
instance.force_disable_maintenance(user)
return Response(status=status.HTTP_200_OK)

View file

@ -41,6 +41,7 @@ class ChannelFilterView(
"destroy": [RBACPermission.Permissions.INTEGRATIONS_WRITE],
"move_to_position": [RBACPermission.Permissions.INTEGRATIONS_WRITE],
"send_demo_alert": [RBACPermission.Permissions.INTEGRATIONS_TEST],
"convert_from_regex_to_jinja2": [RBACPermission.Permissions.INTEGRATIONS_WRITE],
}
model = ChannelFilter
@ -137,9 +138,23 @@ class ChannelFilterView(
@action(detail=True, methods=["post"], throttle_classes=[DemoAlertThrottler])
def send_demo_alert(self, request, pk):
"""Deprecated action. May be used in the older version of the plugin."""
instance = ChannelFilter.objects.get(public_primary_key=pk)
try:
instance.send_demo_alert()
except UnableToSendDemoAlert as e:
raise BadRequest(detail=str(e))
return Response(status=status.HTTP_200_OK)
@action(detail=True, methods=["post"])
def convert_from_regex_to_jinja2(self, request, pk):
instance = self.get_queryset().get(public_primary_key=pk)
if not instance.filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_REGEX:
raise BadRequest(detail="Only regex filtering term type is supported")
serializer_class = self.serializer_class
instance.filtering_term = serializer_class(instance).get_filtering_term_as_jinja2(instance)
instance.filtering_term_type = ChannelFilter.FILTERING_TERM_TYPE_JINJA2
instance.save()
return Response(status=status.HTTP_200_OK, data=serializer_class(instance).data)

View file

@ -39,6 +39,8 @@ class GetObjectMixin:
class MaintenanceAPIView(APIView):
"""Deprecated. Maintenance management is now performed on integrations page (alert_receive_channel/ endpoint))"""
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated, RBACPermission)
@ -101,6 +103,8 @@ class MaintenanceAPIView(APIView):
class MaintenanceStartAPIView(GetObjectMixin, APIView):
"""Deprecated. Maintenance management is now performed on integrations page (alert_receive_channel/ endpoint))"""
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated, RBACPermission)
rbac_permissions = {
@ -137,6 +141,8 @@ class MaintenanceStartAPIView(GetObjectMixin, APIView):
class MaintenanceStopAPIView(GetObjectMixin, APIView):
"""Deprecated. Maintenance management is now performed on integrations page (alert_receive_channel/ endpoint))"""
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated, RBACPermission)
rbac_permissions = {

View file

@ -8,7 +8,7 @@ from apps.api.permissions import RBACPermission
from apps.api.serializers.telegram import TelegramToOrganizationConnectorSerializer
from apps.auth_token.auth import PluginAuthentication
from common.api_helpers.mixins import PublicPrimaryKeyMixin
from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsType, write_chatops_insight_log
from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
class TelegramChannelViewSet(
@ -47,7 +47,7 @@ class TelegramChannelViewSet(
write_chatops_insight_log(
author=user,
event_name=ChatOpsEvent.CHANNEL_DISCONNECTED,
chatops_type=ChatOpsType.TELEGRAM,
chatops_type=ChatOpsTypePlug.TELEGRAM.value,
channel_name=instance.channel_name,
)
instance.delete()

View file

@ -49,7 +49,7 @@ from common.api_helpers.paginators import HundredPageSizePaginator
from common.api_helpers.utils import create_engine_url
from common.insight_log import (
ChatOpsEvent,
ChatOpsType,
ChatOpsTypePlug,
EntityEvent,
write_chatops_insight_log,
write_resource_insight_log,
@ -417,7 +417,7 @@ class UserView(
write_chatops_insight_log(
author=request.user,
event_name=ChatOpsEvent.USER_UNLINKED,
chatops_type=ChatOpsType.SLACK,
chatops_type=ChatOpsTypePlug.SLACK.value,
linked_user=user.username,
linked_user_id=user.public_primary_key,
)
@ -433,7 +433,7 @@ class UserView(
write_chatops_insight_log(
author=request.user,
event_name=ChatOpsEvent.USER_UNLINKED,
chatops_type=ChatOpsType.TELEGRAM,
chatops_type=ChatOpsTypePlug.TELEGRAM.value,
linked_user=user.username,
linked_user_id=user.public_primary_key,
)

View file

@ -80,6 +80,7 @@ class IntegrationSerializer(EagerLoadingMixin, serializers.ModelSerializer, Main
templates = serializers.DictField(required=False)
default_route = serializers.DictField(required=False)
heartbeat = serializers.SerializerMethodField()
description_short = serializers.CharField(max_length=250, required=False)
PREFETCH_RELATED = ["channel_filters"]
SELECT_RELATED = ["organization", "integration_heartbeat"]
@ -89,6 +90,7 @@ class IntegrationSerializer(EagerLoadingMixin, serializers.ModelSerializer, Main
fields = MaintainableObjectSerializerMixin.Meta.fields + [
"id",
"name",
"description_short",
"team_id",
"link",
"type",

View file

@ -17,7 +17,7 @@ def test_get_list_integrations(
make_integration_heartbeat,
):
organization, user, token = make_organization_and_user_with_token()
integration = make_alert_receive_channel(organization, verbal_name="grafana")
integration = make_alert_receive_channel(organization, verbal_name="grafana", description_short="Some description")
default_channel_filter = make_channel_filter(integration, is_default=True)
make_integration_heartbeat(integration)
@ -31,6 +31,7 @@ def test_get_list_integrations(
"id": integration.public_primary_key,
"team_id": None,
"name": "grafana",
"description_short": "Some description",
"link": integration.integration_url,
"type": "grafana",
"default_route": {
@ -162,6 +163,7 @@ def test_update_integration_template(
"id": integration.public_primary_key,
"team_id": None,
"name": "grafana",
"description_short": None,
"link": integration.integration_url,
"type": "grafana",
"default_route": {
@ -223,6 +225,7 @@ def test_update_integration_template_messaging_backend(
"id": integration.public_primary_key,
"team_id": None,
"name": "grafana",
"description_short": None,
"link": integration.integration_url,
"type": "grafana",
"default_route": {
@ -300,6 +303,7 @@ def test_update_resolve_signal_template(
"id": integration.public_primary_key,
"team_id": None,
"name": "grafana",
"description_short": None,
"link": integration.integration_url,
"type": "grafana",
"default_route": {
@ -409,6 +413,7 @@ def test_update_sms_template_with_empty_dict(
"id": integration.public_primary_key,
"team_id": None,
"name": "grafana",
"description_short": None,
"link": integration.integration_url,
"type": "grafana",
"default_route": {
@ -470,6 +475,69 @@ def test_update_integration_name(
"id": integration.public_primary_key,
"team_id": None,
"name": "grafana_updated",
"description_short": None,
"link": integration.integration_url,
"type": "grafana",
"default_route": {
"escalation_chain_id": None,
"id": default_channel_filter.public_primary_key,
"slack": {"channel_id": None, "enabled": True},
"telegram": {"id": None, "enabled": False},
TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False},
},
"heartbeat": {
"link": f"{integration.integration_url}heartbeat/",
},
"templates": {
"grouping_key": None,
"resolve_signal": None,
"acknowledge_signal": None,
"source_link": None,
"slack": {"title": None, "message": None, "image_url": None},
"web": {"title": None, "message": None, "image_url": None},
"sms": {
"title": None,
},
"phone_call": {
"title": None,
},
"telegram": {
"title": None,
"message": None,
"image_url": None,
},
TEST_MESSAGING_BACKEND_FIELD: {
"title": None,
"message": None,
"image_url": None,
},
},
"maintenance_mode": None,
"maintenance_started_at": None,
"maintenance_end_at": None,
}
url = reverse("api-public:integrations-detail", args=[integration.public_primary_key])
response = client.put(url, data=data_for_update, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_200_OK
assert response.data == expected_response
@pytest.mark.django_db
def test_update_integration_name_and_description_short(
make_organization_and_user_with_token, make_alert_receive_channel, make_channel_filter, make_integration_heartbeat
):
organization, user, token = make_organization_and_user_with_token()
integration = make_alert_receive_channel(organization, verbal_name="grafana", description_short="Some description")
default_channel_filter = make_channel_filter(integration, is_default=True)
make_integration_heartbeat(integration)
client = APIClient()
data_for_update = {"name": "grafana_updated"}
expected_response = {
"id": integration.public_primary_key,
"team_id": None,
"name": "grafana_updated",
"description_short": "Some description",
"link": integration.integration_url,
"type": "grafana",
"default_route": {
@ -534,6 +602,7 @@ def test_set_default_template(
"id": integration.public_primary_key,
"team_id": None,
"name": "grafana",
"description_short": None,
"link": integration.integration_url,
"type": "grafana",
"default_route": {
@ -601,6 +670,7 @@ def test_set_default_messaging_backend_template(
"id": integration.public_primary_key,
"team_id": None,
"name": "grafana",
"description_short": None,
"link": integration.integration_url,
"type": "grafana",
"default_route": {

View file

@ -687,7 +687,7 @@ class OnCallSchedule(PolymorphicModel):
result["notification_frequency"] = self.get_notify_oncall_shift_freq_display()
result["current_shift_notification"] = self.mention_oncall_start
result["next_shift_notification"] = self.mention_oncall_next
result["notify_empty_oncall"] = self.get_notify_empty_oncall_display
result["notify_empty_oncall"] = self.get_notify_empty_oncall_display()
return result
@property

View file

@ -9,7 +9,7 @@ from apps.slack.constants import SLACK_INVALID_AUTH_RESPONSE, SLACK_WRONG_TEAM_N
from apps.slack.slack_client import SlackClientWithErrorHandling
from apps.slack.slack_client.exceptions import SlackAPIException, SlackAPITokenException
from apps.user_management.models.user import User
from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsType, write_chatops_insight_log
from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
logger = logging.getLogger(__name__)
@ -65,7 +65,7 @@ class SlackTeamIdentity(models.Model):
self.installed_via_granular_permissions = True
self.save()
write_chatops_insight_log(
author=user, event_name=ChatOpsEvent.WORKSPACE_CONNECTED, chatops_type=ChatOpsType.SLACK
author=user, event_name=ChatOpsEvent.WORKSPACE_CONNECTED, chatops_type=ChatOpsTypePlug.SLACK.value
)
def get_cached_channels(self, search_term=None, slack_id=None):

View file

@ -53,7 +53,7 @@ from apps.slack.scenarios.slack_usergroup import STEPS_ROUTING as SLACK_USERGROU
from apps.slack.slack_client import SlackClientWithErrorHandling
from apps.slack.slack_client.exceptions import SlackAPIException, SlackAPITokenException
from apps.slack.tasks import clean_slack_integration_leftovers, unpopulate_slack_user_identities
from common.insight_log import ChatOpsEvent, ChatOpsType, write_chatops_insight_log
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
from common.oncall_gateway import delete_slack_connector
from .models import SlackMessage, SlackTeamIdentity, SlackUserIdentity
@ -555,7 +555,7 @@ class ResetSlackView(APIView):
write_chatops_insight_log(
author=request.user,
event_name=ChatOpsEvent.WORKSPACE_DISCONNECTED,
chatops_type=ChatOpsType.SLACK,
chatops_type=ChatOpsTypePlug.SLACK.value,
)
unpopulate_slack_user_identities(organization.pk, True)
response = Response(status=200)

View file

@ -14,7 +14,7 @@ from common.constants.slack_auth import (
SLACK_AUTH_SLACK_USER_ALREADY_CONNECTED_ERROR,
SLACK_AUTH_WRONG_WORKSPACE_ERROR,
)
from common.insight_log import ChatOpsEvent, ChatOpsType, write_chatops_insight_log
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
from common.oncall_gateway import check_slack_installation_possible, create_slack_connector
logger = logging.getLogger(__name__)
@ -74,7 +74,7 @@ def connect_user_to_slack(response, backend, strategy, user, organization, *args
write_chatops_insight_log(
author=user,
event_name=ChatOpsEvent.USER_LINKED,
chatops_type=ChatOpsType.SLACK,
chatops_type=ChatOpsTypePlug.SLACK.value,
linked_user=user.username,
linked_user_id=user.public_primary_key,
)

View file

@ -10,7 +10,7 @@ from telegram import error
from apps.alerts.models import AlertGroup
from apps.telegram.client import TelegramClient
from apps.telegram.models import TelegramMessage
from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsType, write_chatops_insight_log
from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
logger = logging.getLogger(__name__)
@ -102,7 +102,7 @@ class TelegramToOrganizationConnector(models.Model):
write_chatops_insight_log(
author=author,
event_name=ChatOpsEvent.DEFAULT_CHANNEL_CHANGED,
chatops_type=ChatOpsType.TELEGRAM,
chatops_type=ChatOpsTypePlug.TELEGRAM.value,
prev_channel=old_default_channel.channel_name if old_default_channel else None,
new_channel=self.channel_name,
)

View file

@ -6,7 +6,7 @@ from django.db import models
from django.utils import timezone
from apps.telegram.models import TelegramToOrganizationConnector
from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsType, write_chatops_insight_log
from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
class TelegramChannelVerificationCode(models.Model):
@ -66,14 +66,14 @@ class TelegramChannelVerificationCode(models.Model):
write_chatops_insight_log(
author=code_instance.author,
event_name=ChatOpsEvent.CHANNEL_CONNECTED,
chatops_type=ChatOpsType.TELEGRAM,
chatops_type=ChatOpsTypePlug.TELEGRAM.value,
channel_name=channel_name,
)
if not connector_exists:
write_chatops_insight_log(
author=code_instance.author,
event_name=ChatOpsEvent.DEFAULT_CHANNEL_CHANGED,
chatops_type=ChatOpsType.TELEGRAM,
chatops_type=ChatOpsTypePlug.TELEGRAM.value,
prev_channel=None,
new_channel=channel_name,
)

View file

@ -6,7 +6,7 @@ from django.db import IntegrityError, models
from django.utils import timezone
from apps.telegram.models import TelegramToUserConnector
from common.insight_log import ChatOpsEvent, ChatOpsType, write_chatops_insight_log
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
class TelegramVerificationCode(models.Model):
@ -48,7 +48,7 @@ class TelegramVerificationCode(models.Model):
write_chatops_insight_log(
author=user,
event_name=ChatOpsEvent.USER_LINKED,
chatops_type=ChatOpsType.TELEGRAM,
chatops_type=ChatOpsTypePlug.TELEGRAM.value,
linked_user=user.username,
linked_user_id=user.public_primary_key,
)

View file

@ -14,7 +14,7 @@ from apps.alerts.models import MaintainableObject
from apps.alerts.tasks import disable_maintenance
from apps.slack.utils import post_message_to_channel
from apps.user_management.subscription_strategy import FreePublicBetaSubscriptionStrategy
from common.insight_log import ChatOpsEvent, ChatOpsType, write_chatops_insight_log
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
from common.oncall_gateway import create_oncall_connector, delete_oncall_connector, delete_slack_connector
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
@ -302,7 +302,7 @@ class Organization(MaintainableObject):
write_chatops_insight_log(
author=user,
event_name=ChatOpsEvent.DEFAULT_CHANNEL_CHANGED,
chatops_type=ChatOpsType.SLACK,
chatops_type=ChatOpsTypePlug.SLACK.value,
prev_channel=old_channel_name,
new_channel=channel_name,
)

View file

@ -274,4 +274,5 @@ class WebhookResponse(models.Model):
content = models.TextField(null=True, default=None)
def json(self):
return json.loads(self.content)
if self.content:
return json.loads(self.content)

View file

@ -297,6 +297,7 @@ def test_execute_webhook_using_responses_data(
public_primary_key="response-1",
),
trigger_type=Webhook.TRIGGER_FIRING,
status_code=200,
content=json.dumps({"id": "third-party-id"}),
)
make_webhook_response(
@ -306,8 +307,20 @@ def test_execute_webhook_using_responses_data(
public_primary_key="response-2",
),
trigger_type=Webhook.TRIGGER_ACKNOWLEDGE,
status_code=200,
content=json.dumps({"id": "third-party-id", "status": "updated"}),
)
# webhook wasn't executed because of some error, there is no content or status_code
make_webhook_response(
alert_group=alert_group,
webhook=make_custom_webhook(
organization=organization,
public_primary_key="response-3",
),
trigger_type=Webhook.TRIGGER_SILENCE,
content=None,
status_code=None,
)
mock_response = MockResponse()
with patch("apps.webhooks.utils.socket.gethostbyname") as mock_gethostbyname:

View file

@ -101,35 +101,6 @@ class UsersFilteredByOrganizationField(serializers.Field):
return queryset.filter(organization=request.user.organization, public_primary_key__in=data).distinct()
class WritableSerializerMethodField(serializers.SerializerMethodField):
"""
Please, NEVER use this field.
It was a mistake to create this one due to necessity to dig deep in drf to fix bugs there.
This field is a workaround to allow to write into SerializerMethodField.
"""
def __init__(self, method_name=None, **kwargs):
self.method_name = method_name
self.setter_method_name = kwargs.pop("setter_method_name", None)
self.deserializer_field = kwargs.pop("deserializer_field")
kwargs["source"] = "*"
super(serializers.SerializerMethodField, self).__init__(**kwargs)
def bind(self, field_name, parent):
retval = super().bind(field_name, parent)
if not self.setter_method_name:
self.setter_method_name = f"set_{field_name}"
return retval
def to_internal_value(self, data):
value = self.deserializer_field.to_internal_value(data)
method = getattr(self.parent, self.setter_method_name)
method(value)
return {self.method_name: value}
class CustomTimeField(fields.TimeField):
def to_representation(self, value):
result = super().to_representation(value)

View file

@ -283,11 +283,16 @@ BEHAVIOUR_TEMPLATE_NAMES = [RESOLVE_CONDITION, ACKNOWLEDGE_CONDITION, GROUPING_I
ALL_TEMPLATE_NAMES = APPEARANCE_TEMPLATE_NAMES + BEHAVIOUR_TEMPLATE_NAMES
class PreviewTemplateException(Exception):
pass
class PreviewTemplateMixin:
@action(methods=["post"], detail=True)
def preview_template(self, request, pk):
template_body = request.data.get("template_body", None)
template_name = request.data.get("template_name", None)
payload = request.data.get("payload", None)
if template_body is None or template_name is None:
response = {"preview": None}
@ -295,18 +300,21 @@ class PreviewTemplateMixin:
notification_channel, attr_name = self.parse_name_and_notification_channel(template_name)
if attr_name is None:
raise BadRequest(detail={"template_name": "Attr name is required"})
raise BadRequest(detail={"template_name": "Template name is missing"})
if attr_name not in ALL_TEMPLATE_NAMES:
raise BadRequest(detail={"template_name": "Unknown attr name"})
raise BadRequest(detail={"template_name": "Unknown template name"})
if attr_name in APPEARANCE_TEMPLATE_NAMES:
if notification_channel is None:
raise BadRequest(detail={"notification_channel": "notification_channel is required"})
if notification_channel not in NOTIFICATION_CHANNEL_OPTIONS:
raise BadRequest(detail={"notification_channel": "Unknown notification_channel"})
alert_to_template = self.get_alert_to_template()
if alert_to_template is None:
raise BadRequest(detail="Alert to preview does not exist")
try:
alert_to_template = self.get_alert_to_template(payload=payload)
if alert_to_template is None:
raise BadRequest(detail="Alert to preview does not exist")
except PreviewTemplateException as e:
raise BadRequest(detail=str(e))
if attr_name in APPEARANCE_TEMPLATE_NAMES:
@ -337,7 +345,7 @@ class PreviewTemplateMixin:
response = {"preview": templated_attr}
return Response(response, status=status.HTTP_200_OK)
def get_alert_to_template(self):
def get_alert_to_template(self, payload=None):
raise NotImplementedError
@staticmethod

View file

@ -1,6 +1,6 @@
class OperationCouldNotBePerformedError(Exception):
"""
Indicates that operation could not be performed due to to application logic.
Indicates that operation could not be performed due to application logic.
E.g. you can't ack resolved AlertGroup
"""

View file

@ -1,3 +1,3 @@
from .chatops_insight_logs import ChatOpsEvent, ChatOpsType, write_chatops_insight_log # noqa
from .chatops_insight_logs import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log # noqa
from .maintenance_insight_log import MaintenanceEvent, write_maintenance_insight_log # noqa
from .resource_insight_logs import EntityEvent, write_resource_insight_log # noqa

View file

@ -9,8 +9,8 @@ logger = logging.getLogger(__name__)
class ChatOpsEvent(enum.Enum):
WORKSPACE_CONNECTED = "started"
WORKSPACE_DISCONNECTED = "finished"
WORKSPACE_CONNECTED = "workspace_connected"
WORKSPACE_DISCONNECTED = "workspace_disconnected"
CHANNEL_CONNECTED = "channel_connected"
CHANNEL_DISCONNECTED = "channel_disconnected"
USER_LINKED = "user_linked"
@ -18,17 +18,13 @@ class ChatOpsEvent(enum.Enum):
DEFAULT_CHANNEL_CHANGED = "default_channel_changed"
class ChatOpsType(enum.Enum):
# Keep in sync with messaging backends' id.
# In perfect world backend_ids should be used intead of this enums
# It can be achieved when we move refactor slack and telegram to use the messaging_backend system.
SLACK = "SLACK"
MSTEAMS = "MSTEAMS"
TELEGRAM = "TELEGRAM"
MOBILE_APP = "MOBILE_APP"
class ChatOpsTypePlug(enum.Enum):
# ChatOpsTypePlug provides backend_id string for chatops integration not supporting messaging_backends.
SLACK = "slack"
TELEGRAM = "telegram"
def write_chatops_insight_log(author, event_name: ChatOpsEvent, chatops_type: ChatOpsType, **kwargs):
def write_chatops_insight_log(author, event_name: ChatOpsEvent, chatops_type: str, **kwargs):
try:
organization = author.organization
@ -37,7 +33,7 @@ def write_chatops_insight_log(author, event_name: ChatOpsEvent, chatops_type: Ch
user_id = author.public_primary_key
username = json.dumps(author.username)
log_line = f"tenant_id={tenant_id} author_id={user_id} author={username} action_type=chat_ops action_name={event_name.value} chat_ops_type={chatops_type.value}" # noqa
log_line = f"tenant_id={tenant_id} author_id={user_id} author={username} action_type=chat_ops action_name={event_name.value} chat_ops_type={chatops_type.lower()}" # noqa
for k, v in kwargs.items():
log_line += f" {k}={json.dumps(v)}"

View file

@ -8,8 +8,8 @@ logger = logging.getLogger(__name__)
def is_insight_logs_enabled(organization):
"""
is_insight_logs_enabled checks if inside logs enabled for given organization.
Now it checks if oncall is deployed on same cluster that its grafana instance to be able to forward logs.
Or if it's Open Source :)
Now it checks if oncall is deployed on same cluster that its grafana instance to be able to forward logs
to Loki through logs-forwarder.
"""
logger.info(
"is_insight_logs_enabled: "
@ -17,4 +17,4 @@ def is_insight_logs_enabled(organization):
f"ONCALL_BACKEND_REGION={settings.ONCALL_BACKEND_REGION} "
f"cluster_slug={organization.cluster_slug}"
)
return settings.IS_OPEN_SOURCE or settings.ONCALL_BACKEND_REGION == organization.cluster_slug
return not settings.IS_OPEN_SOURCE and settings.ONCALL_BACKEND_REGION == organization.cluster_slug

View file

@ -21,7 +21,7 @@ def write_maintenance_insight_log(instance, user, event: MaintenanceEvent):
team = instance.get_team()
entity_name = json.dumps(instance.insight_logs_verbal)
entity_id = instance.public_primary_key
maintenance_mode = instance.get_maintenance_mode_display()
maintenance_mode = instance.get_maintenance_mode_display().lower()
if is_insight_logs_enabled(organization):
log_line = f"tenant_id={tenant_id} action_type=maintenance action_name={event.value} maintenance_mode={maintenance_mode} resource_id={entity_id} resource_name={entity_name}" # noqa
@ -32,7 +32,7 @@ def write_maintenance_insight_log(instance, user, event: MaintenanceEvent):
if user:
username = json.dumps(user.username)
user_id = user.public_primary_key
log_line += f" user_id={user_id} username={username} "
log_line += f" author_id={user_id} author={username}"
insight_logger.info(log_line)
except Exception as e:
logger.warning(f"insight_log.failed_to_write_maintenance_insight_log exception={e}")

View file

@ -71,7 +71,7 @@ def write_resource_insight_log(instance: InsightLoggable, author, event: EntityE
entity_id = instance.id
entity_name = json.dumps(instance.insight_logs_verbal)
metadata = instance.insight_logs_metadata
log_line = f"tenant_id={tenant_id} author_id={author_id} author={author} action_type=resource action={event.value} resource_type={entity_type} resource_id={entity_id} resource_name={entity_name}" # noqa
log_line = f"tenant_id={tenant_id} author_id={author_id} author={author} action_type=resource action_name={event.value} resource_type={entity_type} resource_id={entity_id} resource_name={entity_name}" # noqa
for k, v in metadata.items():
log_line += f" {k}={json.dumps(v)}"
if prev_state and new_state:
@ -82,7 +82,7 @@ def write_resource_insight_log(instance: InsightLoggable, author, event: EntityE
log_line += f' new_state="{new_state}"'
insight_logger.info(log_line)
except Exception as e:
logger.warning(f"insight_log.failed_to_write_entity_insight_log exception={e}")
logger.warning(f"insight_log.failed_to_write_entity_insight_log exception={e} instance_id={instance.id}")
def state_diff_finder(prev_state: dict, new_state: dict):

View file

@ -37,3 +37,17 @@ def regex_match(pattern, value):
return bool(re.match(value, pattern))
except (ValueError, AttributeError, TypeError):
return None
def regex_search(pattern, value):
try:
return bool(re.search(value, pattern))
except (ValueError, AttributeError, TypeError):
return None
def json_dumps(value):
try:
return json.dumps(value)
except (ValueError, AttributeError, TypeError):
return None

View file

@ -3,7 +3,15 @@ from jinja2 import BaseLoader
from jinja2.exceptions import SecurityError
from jinja2.sandbox import SandboxedEnvironment
from .filters import datetimeformat, iso8601_to_time, regex_match, regex_replace, to_pretty_json
from .filters import (
datetimeformat,
iso8601_to_time,
json_dumps,
regex_match,
regex_replace,
regex_search,
to_pretty_json,
)
def raise_security_exception(name):
@ -19,3 +27,5 @@ jinja_template_env.globals["time"] = timezone.now
jinja_template_env.globals["range"] = lambda *args: raise_security_exception("range")
jinja_template_env.filters["regex_replace"] = regex_replace
jinja_template_env.filters["regex_match"] = regex_match
jinja_template_env.filters["regex_search"] = regex_search
jinja_template_env.filters["json_dumps"] = json_dumps

View file

@ -14,6 +14,14 @@ def test_apply_jinja_template():
assert payload == result
def test_apply_jinja_template_json_dumps():
payload = {"name": "test"}
result = apply_jinja_template("{{ payload | json_dumps }}", payload)
expected = json.dumps(payload)
assert result == expected
def test_apply_jinja_template_regex_match():
payload = {"name": "test"}
@ -26,6 +34,19 @@ def test_apply_jinja_template_regex_match():
apply_jinja_template("{{ payload.name | regex_match('*') }}", payload)
def test_apply_jinja_template_regex_search():
payload = {"name": "test"}
assert apply_jinja_template("{{ payload.name | regex_search('.*') }}", payload) == "True"
assert apply_jinja_template("{{ payload.name | regex_search('tes') }}", payload) == "True"
assert apply_jinja_template("{{ payload.name | regex_search('est') }}", payload) == "True"
assert apply_jinja_template("{{ payload.name | regex_search('test1') }}", payload) == "False"
# Check that exception is raised when regex is invalid
with pytest.raises(JinjaTemplateError):
apply_jinja_template("{{ payload.name | regex_search('*') }}", payload)
def test_apply_jinja_template_bad_syntax_error():
with pytest.raises(JinjaTemplateError):
apply_jinja_template("{{%", payload={})

View file

@ -2,7 +2,13 @@
apiVersion: batch/v1
kind: Job
metadata:
{{- if .Values.migrate.useHook }}
name: {{ printf "%s-migrate" (include "oncall.engine.fullname" .) }}
annotations:
"helm.sh/hook": post-install,post-upgrade
{{- else }}
name: {{ printf "%s-migrate-%s" (include "oncall.engine.fullname" .) (now | date "2006-01-02-15-04-05") }}
{{- end }}
labels:
{{- include "oncall.engine.labels" . | nindent 4 }}
spec:

View file

@ -150,6 +150,8 @@ migrate:
enabled: true
# TTL can be unset by setting ttlSecondsAfterFinished: ""
ttlSecondsAfterFinished: 20
# use a helm hook to manage the migration job
useHook: false
# Additional env variables to add to deployments
env: {}

View file

@ -79,30 +79,22 @@ docker run --rm \
-e PAGERDUTY_API_TOKEN="<PAGERDUTY_API_TOKEN>" \
-e ONCALL_API_URL="<ONCALL_API_URL>" \
-e ONCALL_API_TOKEN="<ONCALL_API_TOKEN>" \
-e ONCALL_DEFAULT_CONTACT_METHOD="sms" \
-e MODE="migrate" \
pd-oncall-migrator
```
### Migrate unsupported user notification rules
It's possible to specify a default contact method type for user notification rules that cannot be migrated as-is by
changing the `ONCALL_DEFAULT_CONTACT_METHOD` env variable.
Options are: `email`, `sms`, `phone_call`, `slack`, `telegram`, `mobile_app` (default is `email`).
### Migrate unsupported integration types
It's possible to migrate unsupported integration types to [Grafana OnCall incoming webhooks](https://grafana.com/docs/oncall/latest/integrations/available-integrations/configure-webhook/).
by changing UNSUPPORTED_INTEGRATION_TO_WEBHOOKS env variable:
To enable this feature, set env variable `UNSUPPORTED_INTEGRATION_TO_WEBHOOKS` to `true`:
```shell
docker run --rm \
-e PAGERDUTY_API_TOKEN="<PAGERDUTY_API_TOKEN>" \
-e ONCALL_API_URL="<ONCALL_API_URL>" \
-e ONCALL_API_TOKEN="<ONCALL_API_TOKEN>" \
-e ONCALL_DEFAULT_CONTACT_METHOD="sms" \
-e MODE="migrate" \
-e UNSUPPORTED_INTEGRATION_TO_WEBHOOKS="true" \
-e MODE="migrate" \
pd-oncall-migrator
```

View file

@ -98,7 +98,7 @@ def main() -> None:
rulesets = None
if EXPERIMENTAL_MIGRATE_EVENT_RULES:
print("▶ Fetching event rules (rulesets) ...")
print("▶ Fetching event rules (global rulesets)...")
rulesets = session.list_all("rulesets")
for ruleset in rulesets:
rules = session.list_all(f"rulesets/{ruleset['id']}/rules")
@ -173,7 +173,7 @@ def main() -> None:
print(TAB + format_integration(integration))
if rulesets is not None:
print("▶ Migrating event rules (rulesets) ...")
print("▶ Migrating event rules (global rulesets)...")
for ruleset in rulesets:
if not ruleset["flawed_escalation_policies"]:
migrate_ruleset(ruleset, escalation_policies, services)

View file

@ -14,9 +14,6 @@ ONCALL_API_URL = urljoin(
)
ONCALL_DELAY_OPTIONS = [1, 5, 15, 30, 60]
ONCALL_DEFAULT_CONTACT_METHOD = "notify_by_" + os.getenv(
"ONCALL_DEFAULT_CONTACT_METHOD", default="email"
)
PAGERDUTY_TO_ONCALL_CONTACT_METHOD_MAP = {
"sms_contact_method": "notify_by_sms",
"phone_contact_method": "notify_by_phone_call",

View file

@ -73,7 +73,12 @@ def create(path: str, payload: dict) -> dict:
def delete(path: str) -> None:
api_call("delete", path)
try:
api_call("delete", path)
except requests.exceptions.HTTPError as e:
# ignore 404s on delete so deleting resources manually while running the script doesn't break it
if e.response.status_code != 404:
raise
def update(path: str, payload: dict) -> dict:

View file

@ -76,7 +76,7 @@ def format_integration(integration: dict) -> str:
else:
# check if integration not supported, but UNSUPPORTED_INTEGRATION_TO_WEBHOOKS set
if integration.get("converted_to_webhook", False):
result = "{} {} Webhook integration will be created, Grafana OnCall not support this type directly ".format(
result = "{} {} cannot find appropriate Grafana OnCall integration type, integration will be migrated with type 'webhook'".format(
WARNING_SIGN, result
)
else:
@ -187,7 +187,7 @@ def format_ruleset(ruleset: dict) -> str:
def ruleset_report(rulesets: list[dict]) -> str:
result = "Event rules (rulesets) report:"
result = "Event rules (global rulesets) report:"
for ruleset in sorted(
rulesets,

View file

@ -1,10 +1,7 @@
import copy
from migrator import oncall_api_client
from migrator.config import (
ONCALL_DEFAULT_CONTACT_METHOD,
PAGERDUTY_TO_ONCALL_CONTACT_METHOD_MAP,
)
from migrator.config import PAGERDUTY_TO_ONCALL_CONTACT_METHOD_MAP
from migrator.utils import remove_duplicates, transform_wait_delay
@ -76,10 +73,8 @@ def transform_notification_rule(
notification_rule: dict, delay: int, user_id: str
) -> list[dict]:
contact_method_type = notification_rule["contact_method"]["type"]
oncall_type = PAGERDUTY_TO_ONCALL_CONTACT_METHOD_MAP[contact_method_type]
oncall_type = PAGERDUTY_TO_ONCALL_CONTACT_METHOD_MAP.get(
contact_method_type, ONCALL_DEFAULT_CONTACT_METHOD
)
notify_rule = {"user_id": user_id, "type": oncall_type, "important": False}
if not delay: