This commit is contained in:
Joey Orlando 2024-08-09 13:57:06 -04:00 committed by GitHub
commit e72d036c42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 561 additions and 91 deletions

View file

@ -122,6 +122,8 @@ This set of permissions is supporting the ability of Grafana OnCall to match use
(deprecated) slack commands.
- **Create and manage user groups** — the permission is used to automatically update user groups linked to on-call
schedules. It will add users once their on-call shift starts and remove them once the on-call shift ends.
- **NOTE**: per [Slack's documentation](https://slack.com/help/articles/212906697-Create-a-user-group), you must have
a paid plan for this feature to work properly
- **Set presence for Grafana OnCall**
## Post-install configuration for Slack integration

View file

@ -2,6 +2,12 @@
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/alertgroups/
title: Alert groups HTTP API
weight: 400
refs:
pagination:
- pattern: /docs/oncall/
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
---
# Alert groups HTTP API
@ -48,6 +54,8 @@ The above command returns JSON structured in the following way:
}
```
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
These available filter parameters should be provided as `GET` arguments:
- `id`

View file

@ -2,6 +2,12 @@
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/alerts/
title: Alerts HTTP API
weight: 100
refs:
pagination:
- pattern: /docs/oncall/
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
---
# Alerts HTTP API
@ -105,6 +111,8 @@ The above command returns JSON structured in the following way:
}
```
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
The following available filter parameters should be provided as `GET` arguments:
- `id`

View file

@ -2,6 +2,12 @@
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/escalation_chains/
title: Escalation chains HTTP API
weight: 200
refs:
pagination:
- pattern: /docs/oncall/
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
---
# Escalation chains HTTP API
@ -89,6 +95,8 @@ The above command returns JSON structured in the following way:
}
```
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
**HTTP request**
`GET {{API_URL}}/api/v1/escalation_chains/`

View file

@ -2,6 +2,12 @@
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/escalation_policies/
title: Escalation policies HTTP API
weight: 300
refs:
pagination:
- pattern: /docs/oncall/
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
---
# Escalation policies HTTP API
@ -144,6 +150,8 @@ The above command returns JSON structured in the following way:
}
```
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
The following available filter parameter should be provided as a `GET` argument:
- `escalation_chain_id`

View file

@ -8,6 +8,11 @@ refs:
destination: /docs/oncall/<ONCALL_VERSION>/configure/integrations/references/alertmanager/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/configure/integrations/references/alertmanager/
pagination:
- pattern: /docs/oncall/
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
---
# Integrations HTTP API
@ -233,6 +238,8 @@ The above command returns JSON structured in the following way:
}
```
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
**HTTP request**
`GET {{API_URL}}/api/v1/integrations/`

View file

@ -2,6 +2,12 @@
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/on_call_shifts/
title: OnCall shifts HTTP API
weight: 600
refs:
pagination:
- pattern: /docs/oncall/
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
---
# OnCall shifts HTTP API
@ -150,6 +156,8 @@ The above command returns JSON structured in the following way:
}
```
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
The following available filter parameters should be provided as `GET` arguments:
- `name` (Exact match)

View file

@ -13,6 +13,11 @@ refs:
destination: /docs/oncall/<ONCALL_VERSION>/configure/integrations/outgoing-webhooks/#event-types
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/configure/integrations/outgoing-webhooks/#event-types
pagination:
- pattern: /docs/oncall/
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
---
# Outgoing webhooks
@ -66,6 +71,8 @@ The above command returns JSON structured in the following way:
}
```
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
## Get webhook
```shell
@ -244,3 +251,5 @@ The above command returns JSON structured in the following way:
"total_pages": 1
}
```
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.

View file

@ -2,6 +2,12 @@
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/personal_notification_rules/
title: Personal notification rules HTTP API
weight: 800
refs:
pagination:
- pattern: /docs/oncall/
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
---
# Personal notification rules HTTP API
@ -122,6 +128,8 @@ The above command returns JSON structured in the following ways:
}
```
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
The following available filter parameters should be provided as `GET` arguments:
- `user_id`

View file

@ -2,6 +2,12 @@
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/resolution_notes/
title: Resolution notes HTTP API
weight: 900
refs:
pagination:
- pattern: /docs/oncall/
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
---
# Resolution notes HTTP API
@ -99,6 +105,8 @@ The above command returns JSON structured in the following way:
}
```
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
The following available filter parameter should be provided as a `GET` argument:
- `alert_group_id`

View file

@ -2,6 +2,12 @@
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/routes/
title: Routes HTTP API
weight: 1100
refs:
pagination:
- pattern: /docs/oncall/
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
---
# Routes HTTP API
@ -134,6 +140,8 @@ The above command returns JSON structured in the following way:
}
```
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
The following available filter parameters should be provided as `GET` arguments:
- `integration_id`

View file

@ -2,6 +2,12 @@
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/schedules/
title: Schedules HTTP API
weight: 1200
refs:
pagination:
- pattern: /docs/oncall/
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
---
# Schedules HTTP API
@ -139,6 +145,8 @@ The above command returns JSON structured in the following way:
}
```
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
The following available filter parameter should be provided as a `GET` argument:
- `name` (Exact match)
@ -307,6 +315,8 @@ The above command returns JSON structured in the following way:
}
```
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
### Caveats
Some notes on the `start_date` and `end_date` query parameters:

View file

@ -2,6 +2,12 @@
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/shift_swaps/
title: Shift swap requests HTTP API
weight: 1200
refs:
pagination:
- pattern: /docs/oncall/
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
---
# Shift swap requests HTTP API
@ -185,6 +191,8 @@ The above command returns JSON structured in the following way:
}
```
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
The following available filter parameters may be provided as a `GET` arguments:
- `starting_after` (an ISO 8601 timestamp string, filter requests starting after the specified datetime)

View file

@ -2,6 +2,12 @@
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/slack_channels/
title: Slack channels HTTP API
weight: 1300
refs:
pagination:
- pattern: /docs/oncall/
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
---
# Slack channels HTTP API
@ -34,6 +40,8 @@ The above command returns JSON structured in the following way:
}
```
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
The following available filter parameter should be provided as a `GET` argument:
- `channel_name`

View file

@ -2,6 +2,12 @@
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/user_groups/
title: OnCall user groups HTTP API
weight: 1400
refs:
pagination:
- pattern: /docs/oncall/
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
---
<!--Used in escalation policies with type = `notify_user_group` and in schedules.-->
@ -41,6 +47,8 @@ The above command returns JSON structured in the following way:
}
```
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
| Parameter | Unique | Description |
| --------- | :----: | :---------------------------------------------------------------------------------------------------- |
| `id` | Yes | User Group ID |

View file

@ -2,6 +2,12 @@
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/users/
title: Grafana OnCall users HTTP API
weight: 1500
refs:
pagination:
- pattern: /docs/oncall/
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
---
# Grafana OnCall users HTTP API
@ -90,7 +96,7 @@ The above command returns JSON structured in the following way:
}
```
This endpoint retrieves all users.
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
The following available filter parameter should be provided as a `GET` argument:

View file

@ -15,6 +15,7 @@ from apps.labels.utils import is_labels_feature_enabled
class Feature(enum.StrEnum):
MSTEAMS = "msteams"
SLACK = "slack"
UNIFIED_SLACK = "unified_slack"
TELEGRAM = "telegram"
LIVE_SETTINGS = "live_settings"
GRAFANA_CLOUD_NOTIFICATIONS = "grafana_cloud_notifications"
@ -46,6 +47,9 @@ class FeaturesAPIView(APIView):
if settings.FEATURE_SLACK_INTEGRATION_ENABLED:
enabled_features.append(Feature.SLACK)
if settings.UNIFIED_SLACK_APP_ENABLED:
enabled_features.append(Feature.UNIFIED_SLACK)
if settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED:
enabled_features.append(Feature.TELEGRAM)

View file

@ -3,6 +3,7 @@ import logging
import typing
from django.conf import settings
from google.auth.exceptions import RefreshError
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
@ -24,11 +25,24 @@ class GoogleCalendarEvent:
self.end_time_utc = self._end_time.astimezone(datetime.timezone.utc)
class GoogleCalendarHTTPError(Exception):
class _GoogleCalendarHTTPError(Exception):
def __init__(self, http_error) -> None:
self.error = http_error
class GoogleCalendarGenericHTTPError(_GoogleCalendarHTTPError):
"""Raised when a generic HTTP error occurs when communicating with the Google Calendar API"""
class GoogleCalendarUnauthorizedHTTPError(_GoogleCalendarHTTPError):
"""Raised when an HTTP 403 error occurs when communicating with the Google Calendar API"""
class GoogleCalendarRefreshError(Exception):
def __init__(self, refresh_error) -> None:
self.error = refresh_error
class GoogleCalendarAPIClient:
MAX_NUMBER_OF_CALENDAR_EVENTS_TO_FETCH = 250
"""
@ -89,7 +103,27 @@ class GoogleCalendarAPIClient:
.execute()
)
except HttpError as e:
logger.error(f"GoogleCalendarAPIClient - Error fetching out of office events: {e}")
raise GoogleCalendarHTTPError(e)
if e.status_code == 403:
# this scenario can be encountered when, for some reason, the OAuth2 token that we have
# does not contain the https://www.googleapis.com/auth/calendar.events.readonly scope
# example error:
# <HttpError 403 when requesting https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin=2024-08-08T14%3A00%3A00%2B0000&timeMax=2024-09-07T14%3A00%3A00%2B0000&maxResults=250&singleEvents=true&orderBy=startTime&eventTypes=outOfOffice&alt=json returned "Request had insufficient authentication scopes.". Details: "[{'message': 'Insufficient Permission', 'domain': 'global', 'reason': 'insufficientPermissions'}]"> # noqa: E501
logger.error(f"GoogleCalendarAPIClient - HttpError 403 when fetching out of office events: {e}")
raise GoogleCalendarUnauthorizedHTTPError(e)
logger.error(f"GoogleCalendarAPIClient - HttpError when fetching out of office events: {e}")
raise GoogleCalendarGenericHTTPError(e)
except RefreshError as e:
# TODO: come back and solve this properly once we get better logging output
# it seems like right now we are seeing RefreshError in two different scenarios:
# 1. RefreshError('invalid_grant: Account has been deleted', {'error': 'invalid_grant', 'error_description': 'Account has been deleted'})
# 2. RefreshError('invalid_grant: Token has been expired or revoked.', {'error': 'invalid_grant', 'error_description': 'Token has been expired or revoked.'})
# https://stackoverflow.com/a/49024030/3902555
logger.error(
f"GoogleCalendarAPIClient - RefreshError when fetching out of office events: {e} "
# NOTE: remove e.args after debugging how to dig into the error details
f"args={e.args}"
)
raise GoogleCalendarRefreshError(e)
return [GoogleCalendarEvent(event) for event in events_result.get("items", [])]

View file

@ -3,7 +3,12 @@ import logging
from celery.utils.log import get_task_logger
from apps.google import constants
from apps.google.client import GoogleCalendarAPIClient, GoogleCalendarHTTPError
from apps.google.client import (
GoogleCalendarAPIClient,
GoogleCalendarGenericHTTPError,
GoogleCalendarRefreshError,
GoogleCalendarUnauthorizedHTTPError,
)
from apps.google.models import GoogleOAuth2User
from apps.schedules.models import OnCallSchedule, ShiftSwapRequest
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
@ -33,8 +38,16 @@ def sync_out_of_office_calendar_events_for_user(google_oauth2_user_pk: int) -> N
try:
out_of_office_events = google_api_client.fetch_out_of_office_events()
except GoogleCalendarHTTPError:
logger.info(f"Failed to fetch out of office events for user {user_id}")
except GoogleCalendarUnauthorizedHTTPError:
# this happens because the user's access token is (somehow) missing the
# https://www.googleapis.com/auth/calendar.events.readonly scope
# they will need to reconnect their Google account and grant us the necessary scopes, retrying will not help
logger.exception(f"Failed to fetch out of office events for user {user_id} due to an unauthorized HTTP error")
# TODO: come back and solve this properly once we get better logging output
# user.reset_google_oauth2_settings()
return
except (GoogleCalendarRefreshError, GoogleCalendarGenericHTTPError):
logger.exception(f"Failed to fetch out of office events for user {user_id}")
return
for out_of_office_event in out_of_office_events:

View file

@ -125,7 +125,7 @@ def test_setup(
user_name = "Bob Smith"
user = make_user_for_organization(
organization,
# normally this 👇 is done via User.finish_google_oauth2_connection_flow.. but since we're creating
# normally this 👇 is done via User.save_google_oauth2_settings.. but since we're creating
# the user via a fixture we need to manually add this
google_calendar_settings={
"oncall_schedules_to_consider_for_shift_swaps": [],

View file

@ -355,19 +355,43 @@ class GcomAPIClient(APIClient):
return data
def get_instances(self, query: str, page_size=None):
MAX_RETRIES = 3
if not page_size:
page, _ = self.api_get(query)
yield page
else:
previous_cursor = None
retry_count = 0
cursor = 0
while cursor is not None:
if query:
page_query = query + f"&cursor={cursor}&pageSize={page_size}"
previous_cursor = cursor
page, call_status = self.api_get(f"{query}&cursor={cursor}&pageSize={page_size}")
if "nextCursor" in page:
cursor = page["nextCursor"]
yield page
elif retry_count == MAX_RETRIES:
break
else:
page_query = f"?cursor={cursor}&pageSize={page_size}"
page, _ = self.api_get(page_query)
yield page
cursor = page["nextCursor"]
# nextCursor is missing from the response JSON, lets retry the request..
#
# NOTE: this is here because there seems to be a bug in GCOM's API where when using cursor based
# pagination, the request is aborted on the GCOM side but still sends HTTP 200 w/ a partial
# JSON response. This was leading to KeyErrors when trying to read the nextCursor key.
#
# How the JSON is actually properly decoded is aside me 🤷‍♂️, but for now lets simply retry the
# request if this scenario arises
#
# See this conversation for more context
# https://raintank-corp.slack.com/archives/C0K031RP1/p1723158123932529
logger.warning(
f"GcomAPIClient.get_instances response was missing nextCursor key, likely a decoding error. "
f"http_response={page} call_status={call_status}"
)
cursor = previous_cursor # retry the request using the previous nextCursor value
retry_count += 1
def _is_stack_in_certain_state(self, stack_id: str, state: str) -> bool:
instance_info = self.get_instance_info(stack_id)

View file

@ -1,5 +1,5 @@
import uuid
from unittest.mock import patch
from unittest.mock import call, patch
import pytest
@ -54,6 +54,80 @@ def test_get_instances_pagination(page_size, expected_pages, expected_items):
assert items == expected_items
@patch("apps.grafana_plugin.helpers.client.APIClient.api_get")
def test_get_instances_pagination_handles_streaming_errors_with_cursor_pagination(mock_api_get):
query = GcomAPIClient.ACTIVE_INSTANCE_QUERY
page_size = 10
next_cursor1 = "abcd1234"
next_cursor2 = "efgh5678"
instance1 = {"id": "1"}
instance2 = {"id": "2"}
instance3 = {"id": "3"}
mock_api_get.side_effect = [
({"items": [instance1], "nextCursor": next_cursor1}, {}),
({"items": [instance2]}, {}), # failed request for the second page (missing nextCursor key)
({"items": [instance2], "nextCursor": next_cursor2}, {}), # retried second page request has nextCursor key
({"items": [instance3], "nextCursor": None}, {}), # last page
]
client = GcomAPIClient("someToken")
objects = []
for page in client.get_instances(query, page_size):
objects.extend(page["items"])
assert instance1 in objects
assert instance2 in objects
assert instance3 in objects
mock_api_get.assert_has_calls(
[
call(f"{query}&cursor=0&pageSize={page_size}"), # 1st page
call(f"{query}&cursor={next_cursor1}&pageSize={page_size}"), # 2nd page, first try
call(f"{query}&cursor={next_cursor1}&pageSize={page_size}"), # 2nd page, retry
call(f"{query}&cursor={next_cursor2}&pageSize={page_size}"), # 3rd page
]
)
@patch("apps.grafana_plugin.helpers.client.APIClient.api_get")
def test_get_instances_pagination_doesnt_infinitely_retry_on_streaming_errors(mock_api_get):
query = GcomAPIClient.ACTIVE_INSTANCE_QUERY
page_size = 10
next_cursor1 = "abcd1234"
instance1 = {"id": "1"}
instance2 = {"id": "2"}
mock_api_get.side_effect = [
({"items": [instance1], "nextCursor": next_cursor1}, {}),
({"items": [instance2]}, {}), # failed request for the second page (missing nextCursor key)
({"items": [instance2]}, {}), # 2nd failed request for the second page
({"items": [instance2]}, {}), # 3rd failed request for the second page
({"items": [instance2]}, {}), # 4th failed request for the second page
]
client = GcomAPIClient("someToken")
objects = []
for page in client.get_instances(query, page_size):
objects.extend(page["items"])
assert instance1 in objects
assert instance2 not in objects
second_page_call = call(f"{query}&cursor={next_cursor1}&pageSize={page_size}")
assert len(mock_api_get.mock_calls) == 5
mock_api_get.assert_has_calls(
[
call(f"{query}&cursor=0&pageSize={page_size}"), # 1st page
second_page_call, # 2nd page, 1st try
second_page_call, # 2nd page, 1st retry
second_page_call, # 2nd page, 2nd retry
second_page_call, # 2nd page, 3rd retry
]
)
@pytest.mark.parametrize(
"query, expected_pages, expected_items",
[

View file

@ -56,6 +56,15 @@ class SlackAPIUsergroupNotFoundError(SlackAPIError):
errors = ("no_such_subteam", "subteam_not_found")
class SlackAPIUsergroupPaidTeamOnlyError(SlackAPIError):
"""
https://api.slack.com/methods/usergroups.create#:~:text=Name%20too%20long.-,paid_teams_only,-Usergroups%20can%20only
https://slack.com/help/articles/212906697-Create-a-user-group
"""
errors = ("paid_teams_only",)
class SlackAPIInvalidUsersError(SlackAPIError):
errors = ("invalid_users",)

View file

@ -16,9 +16,10 @@ from apps.slack.errors import (
SlackAPIPermissionDeniedError,
SlackAPITokenError,
SlackAPIUsergroupNotFoundError,
SlackAPIUsergroupPaidTeamOnlyError,
)
from apps.slack.models import SlackTeamIdentity
from apps.user_management.models.user import User
from apps.slack.models import SlackTeamIdentity, SlackUserIdentity
from apps.user_management.models import User
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
if typing.TYPE_CHECKING:
@ -85,9 +86,11 @@ class SlackUserGroup(models.Model):
return False
@property
def oncall_slack_user_identities(self):
users = set(user for schedule in self.oncall_schedules.get_oncall_users().values() for user in schedule)
slack_user_identities = []
def oncall_slack_user_identities(self) -> list[SlackUserIdentity]:
users: set[User] = set(
user for schedule in self.oncall_schedules.get_oncall_users().values() for user in schedule
)
slack_user_identities: list[SlackUserIdentity] = []
for user in users:
if user.slack_user_identity is not None:
slack_user_identities.append(user.slack_user_identity)
@ -96,7 +99,7 @@ class SlackUserGroup(models.Model):
return slack_user_identities
def update_oncall_members(self):
def update_oncall_members(self) -> None:
slack_ids = [slack_user_identity.slack_id for slack_user_identity in self.oncall_slack_user_identities]
logger.info(f"Updating usergroup {self.slack_id}, members {slack_ids}")
@ -110,20 +113,25 @@ class SlackUserGroup(models.Model):
logger.info(f"Skipping usergroup {self.slack_id}, already populated correctly")
return
logger.info(f"Slack user group {self.slack_id} memberlist in not up-to-date, updating, members {slack_ids}")
logger.info(f"Slack user group {self.slack_id} memberlist in not up-to-date, updating, members {slack_ids}")
try:
self.update_members(slack_ids)
except SlackAPIPermissionDeniedError:
pass
def update_members(self, slack_ids):
def update_members(self, slack_ids: list[str]) -> None:
sc = SlackClient(self.slack_team_identity, enable_ratelimit_retry=True)
try:
sc.usergroups_users_update(usergroup=self.slack_id, users=slack_ids)
except (SlackAPITokenError, SlackAPIUsergroupNotFoundError, SlackAPIInvalidUsersError) as err:
logger.warning(f"Slack usergroup {self.slack_id} update failed: {err}")
except SlackAPIUsergroupPaidTeamOnlyError:
logger.warning(
f"Slack usergroup {self.slack_id} update failed as this feature is only available for paid teams",
exc_info=True,
)
except SlackAPIError as slack_api_error:
logger.warning(f"Slack usergroup {self.slack_id} update failed: {slack_api_error}")
raise

View file

@ -25,6 +25,7 @@ from apps.slack.errors import (
SlackAPIServerError,
SlackAPITokenError,
SlackAPIUsergroupNotFoundError,
SlackAPIUsergroupPaidTeamOnlyError,
SlackAPIUserNotFoundError,
SlackAPIViewNotFoundError,
)
@ -129,6 +130,7 @@ def test_slack_client_generic_error(mock_request, monkeypatch, make_organization
("message_not_found", SlackAPIMessageNotFoundError),
("method_not_supported_for_channel_type", SlackAPIMethodNotSupportedForChannelTypeError),
("no_such_subteam", SlackAPIUsergroupNotFoundError),
("paid_team_only", SlackAPIUsergroupPaidTeamOnlyError),
("not_found", SlackAPIViewNotFoundError),
("permission_denied", SlackAPIPermissionDeniedError),
("plan_upgrade_required", SlackAPIPlanUpgradeRequiredError),

View file

@ -9,6 +9,7 @@ from apps.slack.errors import (
SlackAPIInvalidUsersError,
SlackAPITokenError,
SlackAPIUsergroupNotFoundError,
SlackAPIUsergroupPaidTeamOnlyError,
)
from apps.slack.models import SlackUserGroup
from apps.slack.tasks import (
@ -22,7 +23,7 @@ from apps.user_management.models import Organization
@pytest.mark.django_db
def test_update_members(make_organization_with_slack_team_identity, make_slack_user_group):
organization, slack_team_identity = make_organization_with_slack_team_identity()
_, slack_team_identity = make_organization_with_slack_team_identity()
user_group = make_slack_user_group(slack_team_identity)
slack_ids = ["slack_id_1", "slack_id_2"]
@ -34,13 +35,21 @@ def test_update_members(make_organization_with_slack_team_identity, make_slack_u
@pytest.mark.django_db
@pytest.mark.parametrize("exception", [SlackAPITokenError, SlackAPIUsergroupNotFoundError, SlackAPIInvalidUsersError])
@pytest.mark.parametrize(
"exception",
[
SlackAPITokenError,
SlackAPIUsergroupNotFoundError,
SlackAPIInvalidUsersError,
SlackAPIUsergroupPaidTeamOnlyError,
],
)
def test_slack_user_group_update_errors(
make_organization_with_slack_team_identity,
make_slack_user_group,
exception,
):
organization, slack_team_identity = make_organization_with_slack_team_identity()
_, slack_team_identity = make_organization_with_slack_team_identity()
user_group = make_slack_user_group(slack_team_identity=slack_team_identity)
slack_ids = ["slack_id_1", "slack_id_2"]
@ -56,7 +65,7 @@ def test_slack_user_group_update_errors_raise(
make_organization_with_slack_team_identity,
make_slack_user_group,
):
organization, slack_team_identity = make_organization_with_slack_team_identity()
_, slack_team_identity = make_organization_with_slack_team_identity()
user_group = make_slack_user_group(slack_team_identity=slack_team_identity)
slack_ids = ["slack_id_1", "slack_id_2"]
@ -100,10 +109,10 @@ def test_update_oncall_members(
organization, slack_team_identity = make_organization_with_slack_team_identity()
user_group = make_slack_user_group(slack_team_identity)
user_1, slack_user_identity_1 = make_user_with_slack_user_identity(
_, slack_user_identity_1 = make_user_with_slack_user_identity(
slack_team_identity, organization, slack_id="slack_id_1"
)
user_2, slack_user_identity_2 = make_user_with_slack_user_identity(
_, slack_user_identity_2 = make_user_with_slack_user_identity(
slack_team_identity, organization, slack_id="slack_id_2"
)
@ -158,7 +167,7 @@ def test_start_update_slack_user_group_for_schedules_organization_deleted(
def test_update_or_create_slack_usergroup_from_slack(
mock_usergroups_list, mock_usergroups_users_list, make_organization_with_slack_team_identity
):
organization, slack_team_identity = make_organization_with_slack_team_identity()
_, slack_team_identity = make_organization_with_slack_team_identity()
SlackUserGroup.update_or_create_slack_usergroup_from_slack("test_slack_id", slack_team_identity)
usergroup = SlackUserGroup.objects.get()
@ -182,7 +191,7 @@ def test_update_or_create_slack_usergroup_from_slack(
def test_update_or_create_slack_usergroup_from_slack_group_not_found(
mock_usergroups_list, make_organization_with_slack_team_identity
):
organization, slack_team_identity = make_organization_with_slack_team_identity()
_, slack_team_identity = make_organization_with_slack_team_identity()
SlackUserGroup.update_or_create_slack_usergroup_from_slack("other_id", slack_team_identity)
# no group is created, no error is raised
@ -208,7 +217,7 @@ def test_update_or_create_slack_usergroup_from_slack_group_not_found(
def test_populate_slack_usergroups_for_team(
mock_usergroups_list, mock_usergroups_users_list, make_organization_with_slack_team_identity
):
organization, slack_team_identity = make_organization_with_slack_team_identity()
_, slack_team_identity = make_organization_with_slack_team_identity()
populate_slack_usergroups_for_team(slack_team_identity.pk)
usergroup = SlackUserGroup.objects.get()
@ -226,12 +235,8 @@ def test_get_users_from_members_for_organization(
):
organization, slack_team_identity = make_organization_with_slack_team_identity()
user_1, slack_user_identity_1 = make_user_with_slack_user_identity(
slack_team_identity, organization, slack_id="slack_id_1"
)
user_2, slack_user_identity_2 = make_user_with_slack_user_identity(
slack_team_identity, organization, slack_id="slack_id_2"
)
user_1, _ = make_user_with_slack_user_identity(slack_team_identity, organization, slack_id="slack_id_1")
user_2, _ = make_user_with_slack_user_identity(slack_team_identity, organization, slack_id="slack_id_2")
user_group = make_slack_user_group(slack_team_identity)
user_group.members = ["slack_id_1", "slack_id_2"]
user_group.save(update_fields=["members"])

View file

@ -19,7 +19,7 @@ def persist_access_and_refresh_tokens(backend: typing.Type[BaseAuth], response:
https://medium.com/starthinker/google-oauth-2-0-access-token-and-refresh-token-explained-cccf2fc0a6d9
"""
user.finish_google_oauth2_connection_flow(response)
user.save_google_oauth2_settings(response)
def disconnect_user_google_oauth2_settings(backend: typing.Type[BaseAuth], user: User, *args, **kwargs):
@ -58,6 +58,6 @@ def disconnect_user_google_oauth2_settings(backend: typing.Type[BaseAuth], user:
else:
logger.info(f"Google OAuth2 token for user {user_pk} is already invalid or revoked, ignoring error")
user.finish_google_oauth2_disconnection_flow()
user.reset_google_oauth2_settings()
logger.info(f"Successfully disconnected user {user.pk} from Google OAuth2")

View file

@ -382,8 +382,14 @@ class User(models.Model):
self.alert_group_table_selected_columns = columns
self.save(update_fields=["alert_group_table_selected_columns"])
def finish_google_oauth2_connection_flow(self, google_oauth2_response: "GoogleOauth2Response") -> None:
_obj, created = GoogleOAuth2User.objects.update_or_create(
def save_google_oauth2_settings(self, google_oauth2_response: "GoogleOauth2Response") -> None:
logger.info(
f"Saving Google OAuth2 settings for user {self.pk} "
f"sub={google_oauth2_response.get('sub')} "
f"oauth_scope={google_oauth2_response.get('oauth_scope')}"
)
_, created = GoogleOAuth2User.objects.update_or_create(
user=self,
defaults={
"google_user_id": google_oauth2_response.get("sub"),
@ -398,7 +404,9 @@ class User(models.Model):
}
self.save(update_fields=["google_calendar_settings"])
def finish_google_oauth2_disconnection_flow(self) -> None:
def reset_google_oauth2_settings(self) -> None:
logger.info(f"Resetting Google OAuth2 settings for user {self.pk}")
GoogleOAuth2User.objects.filter(user=self).delete()
self.google_calendar_settings = None

View file

@ -121,7 +121,7 @@ def test_has_google_oauth2_connected(make_organization_and_user, make_google_oau
@pytest.mark.django_db
def test_finish_google_oauth2_connection_flow(make_organization_and_user):
def test_save_google_oauth2_settings(make_organization_and_user):
oauth_response = {
"access_token": "access",
"refresh_token": "refresh",
@ -134,7 +134,7 @@ def test_finish_google_oauth2_connection_flow(make_organization_and_user):
assert GoogleOAuth2User.objects.filter(user=user).exists() is False
assert user.google_calendar_settings is None
user.finish_google_oauth2_connection_flow(oauth_response)
user.save_google_oauth2_settings(oauth_response)
user.refresh_from_db()
google_oauth_user = user.google_oauth2_user
@ -151,7 +151,7 @@ def test_finish_google_oauth2_connection_flow(make_organization_and_user):
"scope": "scope2",
}
user.finish_google_oauth2_connection_flow(oauth_response2)
user.save_google_oauth2_settings(oauth_response2)
user.refresh_from_db()
google_oauth_user = user.google_oauth2_user
@ -162,10 +162,10 @@ def test_finish_google_oauth2_connection_flow(make_organization_and_user):
@pytest.mark.django_db
def test_finish_google_oauth2_disconnection_flow(make_organization_and_user):
def test_reset_google_oauth2_settings(make_organization_and_user):
_, user = make_organization_and_user()
user.finish_google_oauth2_connection_flow(
user.save_google_oauth2_settings(
{
"access_token": "access",
"refresh_token": "refresh",
@ -178,7 +178,7 @@ def test_finish_google_oauth2_disconnection_flow(make_organization_and_user):
assert user.google_oauth2_user is not None
assert user.google_calendar_settings is not None
user.finish_google_oauth2_disconnection_flow()
user.reset_google_oauth2_settings()
user.refresh_from_db()
assert GoogleOAuth2User.objects.filter(user=user).exists() is False

View file

@ -198,6 +198,13 @@ def make_request(
status["request_headers"] = error = e.message
except InvalidWebhookData as e:
status["request_data"] = error = e.message
except requests.exceptions.SSLError as e:
# Don't raise an exception for SSL errors, as they are out of our control and retrying
# isn't going to help. Just show the error to the user and give up
#
# from the docs (https://requests.readthedocs.io/en/latest/user/advanced/#ssl-cert-verification)
# "Requests will throw a SSLError if its unable to verify the certificate"
status["content"] = error = str(e)
except Exception as e:
status["content"] = error = str(e)
exception = e

View file

@ -736,6 +736,56 @@ def test_execute_webhook_errors(
)
@patch(
"apps.webhooks.models.webhook.WebhookSession.request",
side_effect=requests.exceptions.SSLError("SSL error - foo bar"),
)
@patch("apps.webhooks.utils.socket.gethostbyname", return_value="8.8.8.8") # make it a valid URL when resolving name
@pytest.mark.django_db
def test_execute_webhook_ssl_error(
_mock_socket_gethostbyname,
mock_request,
make_organization,
make_alert_receive_channel,
make_alert_group,
make_custom_webhook,
):
url = "https://something.cool/"
expected_error = "SSL error - foo bar"
organization = make_organization()
alert_receive_channel = make_alert_receive_channel(organization)
alert_group = make_alert_group(alert_receive_channel, resolved_at=timezone.now(), resolved=True)
webhook = make_custom_webhook(
organization=organization,
http_method="POST",
trigger_type=Webhook.TRIGGER_RESOLVE,
forward_all=False,
url=url,
)
execute_webhook(webhook.pk, alert_group.pk, None, None)
mock_request.assert_has_calls([call("POST", url, timeout=4, headers={})])
log = webhook.responses.all()[0]
assert log.status_code is None
assert log.content == expected_error
# check log record
log_record = alert_group.log_records.last()
assert log_record.type == AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_CUSTOM_WEBHOOK_ERROR
assert log_record.step_specific_info == {
"trigger": "resolve",
"webhook_id": webhook.public_primary_key,
"webhook_name": webhook.name,
}
assert log_record.reason == expected_error
assert (
log_record.rendered_log_line_action() == f"skipped resolve outgoing webhook `{webhook.name}`: {expected_error}"
)
@pytest.mark.django_db
def test_response_content_limit(
make_organization, make_user_for_organization, make_alert_receive_channel, make_alert_group, make_custom_webhook

View file

@ -57,6 +57,7 @@ module = [
"factory.*",
"fcm_django.*",
"firebase_admin.*",
"google.auth.exceptions.*",
"googleapiclient.discovery.*",
"googleapiclient.errors.*",
"google.oauth2.credentials.*",

View file

@ -82,10 +82,10 @@ export class SlackStore extends BaseStore {
window.location = url_for_redirect;
}
@action.bound
async installSlackIntegration() {
try {
const response = await makeRequestRaw('/login/slack-install-free/', {});
if (response.status === 201) {
this.rootStore.organizationStore.loadCurrentOrganization();
} else if (response.status === 200) {

View file

@ -39,3 +39,18 @@
.infoblock-icon {
margin-top: 24px;
}
.upgradeSlackBtn {
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
}
.upgradeSlackAlert svg {
display: none;
}
.linkToIncidentWrapper {
margin-top: 16px;
}

View file

@ -10,11 +10,13 @@ import {
InlineField,
Input,
Legend,
ConfirmModal,
} from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import { Block } from 'components/GBlock/Block';
import { PluginBridge, SupportedPlugin } from 'components/PluginBridge/PluginBridge';
import { PluginLink } from 'components/PluginLink/PluginLink';
import { Text } from 'components/Text/Text';
import { WithConfirm } from 'components/WithConfirm/WithConfirm';
@ -26,9 +28,11 @@ import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config'
import { SlackChannel } from 'models/slack_channel/slack_channel.types';
import { AppFeature } from 'state/features';
import { WithStoreProps } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
import { UserActions } from 'utils/authorization/authorization';
import { DOCS_SLACK_SETUP, getPluginId } from 'utils/consts';
import { useConfirmModal } from 'utils/hooks';
import { showApiError } from 'utils/utils';
import styles from './SlackSettings.module.css';
@ -116,9 +120,12 @@ class _SlackSettings extends Component<SlackProps, SlackState> {
slackChannelStore: { items: slackChannelItems },
} = store;
const isUnifiedSlackInstalled = !currentOrganization.slack_team_identity.needs_reinstall;
return (
<div className={cx('root')}>
<Legend>Slack App settings</Legend>
{currentOrganization.slack_team_identity.needs_reinstall && <UpgradeToUnifiedSlackBanner />}
<InlineField label="Slack Workspace" grow disabled>
<Input value={currentOrganization?.slack_team_identity?.cached_name} />
</InlineField>
@ -147,33 +154,58 @@ class _SlackSettings extends Component<SlackProps, SlackState> {
/>
<InlineField>
<WithPermissionControlTooltip userAction={UserActions.ChatOpsUpdateSettings}>
<WithConfirm
title="Remove Slack Integration for all of OnCall"
description={
<Alert severity="error" title="WARNING">
<p>Are you sure to delete this Slack Integration?</p>
<p>
Removing the integration will also irreverisbly remove the following data for your OnCall plugin:
</p>
<ul style={{ marginLeft: '20px' }}>
<li>default organization Slack channel</li>
<li>default Slack channels for OnCall Integrations</li>
<li>Slack channels & Slack user groups for OnCall Schedules</li>
<li>linked Slack usernames for OnCall Users</li>
</ul>
<br />
<p>
If you would like to instead remove your linked Slack username, please head{' '}
<PluginLink query={{ page: 'users/me' }}>here</PluginLink>.
</p>
</Alert>
}
confirmationText="DELETE"
>
<Button variant="destructive" onClick={() => this.removeSlackIntegration()}>
Disconnect Slack App
</Button>
</WithConfirm>
{isUnifiedSlackInstalled ? (
<WithConfirm
title="Remove IRM Slack integration"
description={
<Alert severity="error" title="WARNING">
<p>Are you sure to delete this Slack Integration? It will affect both OnCall & Incident.</p>
<p>Removing the integration will irreverisbly remove the following data for IRM;</p>
<ul style={{ marginLeft: '20px' }}>
<li>OnCall default Slack channel</li>
<li>Slack channels for OnCall escalation policies</li>
<li>Slack channels & Slack user groups for OnCall Schedules</li>
<li>linked Slack usernames for OnCall Users</li>
<li>Incident hooks</li>
</ul>
<br />
</Alert>
}
confirmationText="DELETE"
>
<Button variant="destructive" onClick={() => this.removeSlackIntegration()}>
Disconnect Slack App
</Button>
</WithConfirm>
) : (
<WithConfirm
title="Remove Slack Integration for all of OnCall"
description={
<Alert severity="error" title="WARNING">
<p>Are you sure to delete this Slack Integration?</p>
<p>
Removing the integration will also irreverisbly remove the following data for your OnCall plugin:
</p>
<ul style={{ marginLeft: '20px' }}>
<li>default organization Slack channel</li>
<li>default Slack channels for OnCall Integrations</li>
<li>Slack channels & Slack user groups for OnCall Schedules</li>
<li>linked Slack usernames for OnCall Users</li>
</ul>
<br />
<p>
If you would like to instead remove your linked Slack username, please head{' '}
<PluginLink query={{ page: 'users/me' }}>here</PluginLink>.
</p>
</Alert>
}
confirmationText="DELETE"
>
<Button variant="destructive" onClick={() => this.removeSlackIntegration()}>
Disconnect Slack App
</Button>
</WithConfirm>
)}
</WithPermissionControlTooltip>
</InlineField>
<Legend>Additional settings</Legend>
@ -201,19 +233,20 @@ class _SlackSettings extends Component<SlackProps, SlackState> {
</WithPermissionControlTooltip>
</HorizontalGroup>
</InlineField>
{currentOrganization.slack_team_identity.needs_reinstall && (
<>
<Legend>Unified Slack App</Legend>
<InlineField>
<WithPermissionControlTooltip userAction={UserActions.ChatOpsUpdateSettings}>
<Button onClick={this.handleOpenSlackInstructions}>
<HorizontalGroup spacing="xs" align="center">
<Icon name="external-link-alt" className={cx('external-link-style')} /> Reinstall Slack App
</HorizontalGroup>
</Button>
</WithPermissionControlTooltip>
</InlineField>
</>
{isUnifiedSlackInstalled && (
<div className={styles.linkToIncidentWrapper}>
<PluginBridge plugin={SupportedPlugin.Incident}>
<Text type="secondary">
<a
href={`/a/${SupportedPlugin.Incident}/integrations/grate.irm.slack`}
target="_blank"
rel="noreferrer"
>
<Text type="link">Open Slack Incident settings</Text>
</a>
</Text>
</PluginBridge>
</div>
)}
</div>
);
@ -251,6 +284,7 @@ class _SlackSettings extends Component<SlackProps, SlackState> {
const { store } = this.props;
const { showENVVariablesButton } = this.state;
const isLiveSettingAvailable = store.hasFeature(AppFeature.LiveSettings) && showENVVariablesButton;
const isUnifiedSlackEnabled = store.hasFeature(AppFeature.UnifiedSlack);
return (
<VerticalGroup spacing="lg">
@ -261,7 +295,9 @@ class _SlackSettings extends Component<SlackProps, SlackState> {
<SlackNewIcon />
</div>
<Text className={cx('infoblock-text')}>
Connecting Slack App will allow you to manage alert groups in your team Slack workspace.
{isUnifiedSlackEnabled
? 'Connecting Slack App will allow you to manage alert groups and incidents in your team Slack workspace.'
: 'Connecting Slack App will allow you to manage alert groups in your team Slack workspace.'}
</Text>
<Text className={cx('infoblock-text')}>
After a basic workspace connection your team members need to connect their personal Slack accounts in
@ -305,4 +341,61 @@ class _SlackSettings extends Component<SlackProps, SlackState> {
};
}
const UpgradeToUnifiedSlackBanner = observer(() => {
const {
slackStore: { installSlackIntegration },
} = useStore();
const { modalProps, openModal } = useConfirmModal();
return (
<>
<ConfirmModal {...modalProps} />
<Alert
className={styles.upgradeSlackAlert}
severity="success"
title="Upgrade to Grafana IRM unified Slack app"
buttonContent={<div>Upgrade</div>}
>
We've rebranded the OnCall Slack app as the Grafana IRM Slack app, now with incident management features.
<p>Click "Upgrade" to reviewn and approve the new permissions and complete the process.</p>
<p>For more details, check our documentation.</p>
<Button
variant="secondary"
className={styles.upgradeSlackBtn}
onClick={() =>
openModal({
confirmText: 'Confirm',
onConfirm: installSlackIntegration,
confirmButtonVariant: 'primary',
title: `Upgrade to Grafana IRM Slack app`,
description: (
<div>
<p>
You will be redirected to Slack to approve additional permissions for the Grafana IRM Slack app.{' '}
</p>
<p>
These permissions are necessary for incident management. You can view the detailed list of new
permissions here.[LINK]
</p>
<p>After the upgrade, you'll be able to manage incidents in Slack using the Grafana IRM Slack app.</p>
<ul style={{ marginLeft: '20px' }}>
<li>Your OnCall Slack configuration will remain unchanged. </li>
<li>
Your Incident Slack integration will be upgraded to use the Grafana IRM Slack app. Please refer to
the documentation for more details.[LINK]
</li>
</ul>
</div>
),
confirmVariant: 'secondary',
})
}
>
Upgrade
</Button>
</Alert>
</>
);
});
export const SlackSettings = withMobXProviderContext(_SlackSettings);

View file

@ -1,5 +1,6 @@
export enum AppFeature {
Slack = 'slack',
UnifiedSlack = 'unified_slack',
Telegram = 'telegram',
LiveSettings = 'live_settings',
CloudNotifications = 'grafana_cloud_notifications',