Prepare OnCall for Unified Slack App (#4232)

This PR does a bunch of changes to prepare OnCall for Unified Slack App:
1. Install Slack via Chatops-Proxy. This change contains two parts:
getting a Slack install link from chatops-proxy
([code](https://github.com/grafana/oncall/pull/4232/files#diff-437a77d49fc04b92d315651b3df5991000b1ab74cf60aabb21aa77cb2823bf52R46))
and receiving a "slack installed" event from chatops-proxy
([code](https://github.com/grafana/oncall/pull/4232/files#diff-976d106f0962be5c1de5e35582193f68435ed0c17f2defd6bd2857bf6e27f65d)).
Also it means that OnCall doesn't need to register slack_links anymore
when slack is connected/disconnected. These changes are behind
UNIFIED_SLACK_APP_ENABLED flag and should be no-op if flag is not
enabled.
2. Get rid of Multiregionatily restrictions - instrument all slack
interactions with a ProxyMeta - json data telling chatops-proxy where to
route the interaction. Note, that it doesn't apply for "Add to
resolution notes" message action - it will be handled differently in
following PR.
3. Move all chatops-proxy related stuff from common/oncall-gateway to
apps/chatops-proxy

Minor changes:
1. Remove usage of **CHATOPS_V3** flag. Chatops v3 is already released
(It's a refactoring from previous quarter)

---------

Co-authored-by: Vadim Stepanov <vadimkerr@gmail.com>
Co-authored-by: Rares Mardare <rares.mardare@grafana.com>
This commit is contained in:
Innokentii Konstantinov 2024-06-03 17:07:10 +08:00 committed by GitHub
parent 2aa8639e2a
commit 17f448c506
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1286 additions and 770 deletions

View file

@ -6,4 +6,4 @@ registry: ctlptl-registry
kindV1Alpha4Cluster:
nodes:
- role: control-plane
image: kindest/node:v1.24.7
image: kindest/node:v1.27.3

View file

@ -5,6 +5,7 @@ from django.utils.text import Truncator
from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer
from apps.alerts.incident_appearance.templaters import AlertSlackTemplater
from apps.slack.chatops_proxy_routing import make_value
from apps.slack.constants import BLOCK_SECTION_TEXT_MAX_SIZE
from apps.slack.scenarios.scenario_step import ScenarioStep
from apps.slack.types import Block
@ -436,9 +437,8 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer):
data = {
"organization_id": self.alert_group.channel.organization_id,
"alert_group_pk": self.alert_group.pk,
# eventually replace using alert_group_pk with alert_group_public_pk in slack payload
"alert_group_ppk": self.alert_group.public_primary_key,
**kwargs,
}
return json.dumps(data) # Slack block elements allow to pass value as string only (max 2000 chars)
return make_value(data, self.alert_group.channel.organization)

View file

@ -1,4 +1,4 @@
from unittest.mock import patch
from unittest.mock import Mock, patch
import pytest
from django.contrib.auth import REDIRECT_FIELD_NAME
@ -9,6 +9,8 @@ from rest_framework import status
from rest_framework.test import APIClient
from apps.auth_token.constants import SLACK_AUTH_TOKEN_NAME
from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND
from common.constants.slack_auth import SLACK_OAUTH_ACCESS_RESPONSE
@pytest.mark.django_db
@ -16,7 +18,7 @@ from apps.auth_token.constants import SLACK_AUTH_TOKEN_NAME
"backend_name,expected_url",
(
("slack-login", "/a/grafana-oncall-app/users/me"),
("slack-install-free", "/a/grafana-oncall-app/chat-ops"),
(SLACK_INSTALLATION_BACKEND, "/a/grafana-oncall-app/chat-ops"),
),
)
def test_complete_slack_auth_redirect_ok(
@ -71,6 +73,77 @@ def test_complete_slack_auth_redirect_error(
assert response.url == "some-url"
@override_settings(UNIFIED_SLACK_APP_ENABLED=False)
@pytest.mark.django_db
def test_start_slack_ok(
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
):
"""
Covers the case when user starts Slack integration installation via Grafana OnCall
"""
_, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:social-auth", kwargs={"backend": SLACK_INSTALLATION_BACKEND})
mock_do_auth_return = Mock()
mock_do_auth_return.url = "https://slack_oauth_redirect.com"
with patch("apps.api.views.auth.do_auth", return_value=mock_do_auth_return) as mock_do_auth:
response = client.get(url, **make_user_auth_headers(user, token))
assert mock_do_auth.called
assert response.status_code == status.HTTP_200_OK
assert response.json() == "https://slack_oauth_redirect.com"
@override_settings(UNIFIED_SLACK_APP_ENABLED=True)
@pytest.mark.django_db
def test_start_unified_slack_ok(
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
):
"""
Covers the case when user starts Unified Slack integration installation
"""
_, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:social-auth", kwargs={"backend": SLACK_INSTALLATION_BACKEND})
with patch(
"apps.api.views.auth.get_installation_link_from_chatops_proxy", return_value="https://slack_oauth_redirect.com"
):
response = client.get(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json() == "https://slack_oauth_redirect.com"
@override_settings(UNIFIED_SLACK_APP_ENABLED=True)
@patch("apps.api.views.auth.get_installation_link_from_chatops_proxy", return_value=None)
@patch("apps.api.views.auth.get_slack_oauth_response_from_chatops_proxy", return_value=SLACK_OAUTH_ACCESS_RESPONSE)
@patch("apps.api.views.auth.install_slack_integration", return_value=None)
@pytest.mark.django_db
def test_start_slack_ok_via_chatops_proxy_when_already_installed(
mock_install_slack_integration,
mock_get_slack_oauth_response_from_chatops_proxy,
mock_get_installation_link_from_chatops_proxy,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
):
"""
Covers the case when user starts Unified Slack integration installation, but it's already installed.
It might happen if integration was installed from Incident side, but OnCall missed the corresponding event
"""
org, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:social-auth", kwargs={"backend": SLACK_INSTALLATION_BACKEND})
response = client.get(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_201_CREATED
assert mock_install_slack_integration.call_args.args == (org, user, SLACK_OAUTH_ACCESS_RESPONSE)
@pytest.mark.django_db
@patch("apps.social_auth.backends.GoogleOAuth2.get_redirect_uri")
@patch("apps.social_auth.backends.GoogleOAuth2Token.create_auth_token", return_value=("something", "token_string"))

View file

@ -15,7 +15,12 @@ from social_django.utils import psa
from social_django.views import _do_login
from apps.auth_token.auth import GoogleTokenAuthentication, PluginAuthentication, SlackTokenAuthentication
from apps.social_auth.backends import LoginSlackOAuth2V2
from apps.chatops_proxy.utils import (
get_installation_link_from_chatops_proxy,
get_slack_oauth_response_from_chatops_proxy,
)
from apps.slack.installation import install_slack_integration
from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND, LoginSlackOAuth2V2
logger = logging.getLogger(__name__)
@ -25,6 +30,10 @@ logger = logging.getLogger(__name__)
@never_cache
@psa("social:complete")
def overridden_login_social_auth(request: Request, backend: str) -> Response:
"""
overridden_login_social_auth starts the installation of integration which uses OAuth flow.
"""
# We can't just redirect frontend here because we need to make a API call and pass tokens to this view from JS.
# So frontend can't follow our redirect.
# So wrapping and returning URL to redirect as a string.
@ -34,7 +43,26 @@ def overridden_login_social_auth(request: Request, backend: str) -> Response:
status=400,
)
url_to_redirect_to = do_auth(request.backend, redirect_name=REDIRECT_FIELD_NAME).url
if backend == SLACK_INSTALLATION_BACKEND and settings.UNIFIED_SLACK_APP_ENABLED:
"""
Install unified slack integration via chatops-proxy.
1. Get installation link from chatops-proxy
2. If link is not None slack installation already exists on Chatops-Proxy - install using it's oauth response.
"""
try:
link = get_installation_link_from_chatops_proxy(request.user)
if link is not None:
return Response(link, 200)
else:
slack_oauth_response = get_slack_oauth_response_from_chatops_proxy(request.user.organization.stack_id)
install_slack_integration(request.user.organization, request.user, slack_oauth_response)
return Response("slack integration installed", 201)
except Exception as e:
logger.exception("overridden_login_social_auth: Failed to install slack via chatops-proxy: %s", e)
return Response({"error": "something went wrong, try again later"}, 500)
else:
# Otherwise use social-auth.
url_to_redirect_to = do_auth(request.backend, redirect_name=REDIRECT_FIELD_NAME).url
return Response(url_to_redirect_to, 200)

View file

View file

@ -7,6 +7,7 @@ import requests
from django.conf import settings
SERVICE_TYPE_ONCALL = "oncall"
PROVIDER_TYPE_SLACK = "slack"
@dataclass
@ -32,6 +33,15 @@ class Tenant:
msteams_links: List[MSTeamsLink] = field(default_factory=list)
@dataclass
class OAuthInstallation:
id: str
oauth_response: dict
stack_id: int
provider_type: str
provider_id: str
class ChatopsProxyAPIException(Exception):
"""A generic 400 or 500 level exception from the Chatops Proxy API"""
@ -55,7 +65,7 @@ class ChatopsProxyAPIClient:
# OnCall Tenant
def register_tenant(
self, service_tenant_id: str, cluster_slug: str, service_type: str
self, service_tenant_id: str, cluster_slug: str, service_type: str, stack_id: int
) -> tuple[Tenant, requests.models.Response]:
url = f"{self.api_base_url}/tenants/register"
d = {
@ -63,6 +73,7 @@ class ChatopsProxyAPIClient:
"service_tenant_id": service_tenant_id,
"cluster_slug": cluster_slug,
"service_type": service_type,
"stack_id": stack_id,
}
}
response = requests.post(url=url, json=d, headers=self._headers)
@ -131,6 +142,34 @@ class ChatopsProxyAPIClient:
self._check_response(response)
return response.json()["removed"], response
def get_slack_oauth_link(
self, stack_id: int, grafana_user_id: int, app_redirect: str, app_type: str
) -> tuple[str, requests.models.Response]:
url = f"{self.api_base_url}/oauth2/start"
d = {
"stack_id": stack_id,
"grafana_user_id": grafana_user_id,
"app_redirect": app_redirect,
"app_type": app_type,
}
response = requests.post(url=url, json=d, headers=self._headers)
self._check_response(response)
return response.json()["install_link"], response
def get_oauth_installation(
self,
stack_id: int,
provider_type: str,
) -> tuple[OAuthInstallation, requests.models.Response]:
url = f"{self.api_base_url}/oauth_installations/get"
d = {
"stack_id": stack_id,
"provider_type": provider_type,
}
response = requests.post(url=url, json=d, headers=self._headers)
self._check_response(response)
return OAuthInstallation(**response.json()["oauth_installation"]), response
def _check_response(self, response: requests.models.Response):
"""
Wraps an exceptional response to ChatopsProxyAPIException

View file

@ -0,0 +1 @@
from .root_handler import ChatopsEventsHandler # noqa

View file

@ -0,0 +1,50 @@
import logging
import typing
from abc import ABC, abstractmethod
from apps.chatops_proxy.client import PROVIDER_TYPE_SLACK
from apps.slack.installation import SlackInstallationExc, install_slack_integration
from apps.user_management.models import Organization
from .types import INTEGRATION_INSTALLED_EVENT_TYPE, Event, IntegrationInstalledData
logger = logging.getLogger(__name__)
class Handler(ABC):
@classmethod
@abstractmethod
def match(cls, event: Event) -> bool:
pass
@classmethod
@abstractmethod
def handle(cls, event_data: dict) -> None:
pass
class SlackInstallationHandler(Handler):
@classmethod
def match(cls, event: Event) -> bool:
return (
event.get("event_type") == INTEGRATION_INSTALLED_EVENT_TYPE
and event.get("data", {}).get("provider_type") == PROVIDER_TYPE_SLACK
)
@classmethod
def handle(cls, data: dict) -> None:
data = typing.cast(IntegrationInstalledData, data)
stack_id = data.get("stack_id")
user_id = data.get("grafana_user_id")
payload = data.get("payload")
organization = Organization.objects.get(stack_id=stack_id)
user = organization.users.get(user_id=user_id)
try:
install_slack_integration(organization, user, payload)
except SlackInstallationExc as e:
logger.exception(
f'msg="SlackInstallationHandler: Failed to install Slack integration: %s" org_id={organization.id} stack_id={stack_id}',
e,
)

View file

@ -0,0 +1,37 @@
import logging
import typing
from .handlers import Handler, SlackInstallationHandler
from .types import Event
logger = logging.getLogger(__name__)
class ChatopsEventsHandler:
"""
ChatopsEventsHandler is a root handler which receives event from Chatops-Proxy and chooses the handler to process it.
"""
HANDLERS: typing.List[typing.Type[Handler]] = [SlackInstallationHandler]
def handle(self, event_data: Event) -> bool:
"""
handle iterates over all handlers and chooses the first one that matches the event.
Returns True if a handler was found and False otherwise.
"""
logger.info(f"msg=\"ChatopsEventsHandler: Handling\" event_type={event_data.get('event_type')}")
for h in self.HANDLERS:
if h.match(event_data):
logger.info(
f"msg=\"ChatopsEventsHandler: Found matching handler {h.__name__}\" event_type={event_data.get('event_type')}"
)
self._exec(h.handle, event_data.get("data", {}))
return True
logger.error(f"msg=\"ChatopsEventsHandler: No handler found\" event_type={event_data.get('event_type')}")
return False
def _exec(self, handlefunc: typing.Callable[[dict], None], data: dict):
"""
_exec is a helper method to execute a handler's handle method.
"""
return handlefunc(data)

View file

@ -0,0 +1,36 @@
import base64
import binascii
import hashlib
import hmac
def hash(data):
hasher = hashlib.sha256()
hasher.update(data)
return base64.b64encode(hasher.digest()).decode()
def generate_signature(data, secret):
h = hmac.new(secret.encode(), data.encode(), hashlib.sha256)
return binascii.hexlify(h.digest()).decode()
def verify_signature(request, secret) -> bool:
header = request.META.get("HTTP_X_CHATOPS_SIGNATURE")
if not header:
return False
signatures = header.split(",")
s = dict(pair.split("=") for pair in signatures)
t = s.get("t")
v1 = s.get("v1")
payload = request.body
body_hash = hash(payload)
string_to_sign = f"{body_hash}:{t}:v1"
expected = generate_signature(string_to_sign, secret)
if expected != v1:
return False
return True

View file

@ -0,0 +1,17 @@
import typing
INTEGRATION_INSTALLED_EVENT_TYPE = "integration_installed"
INTEGRATION_UNINSTALLED_EVENT_TYPE = "integration_uninstalled"
class Event(typing.TypedDict):
event_type: str
data: dict
class IntegrationInstalledData(typing.TypedDict):
oauth_installation_id: int
provider_type: str
stack_id: int
grafana_user_id: int
payload: dict

View file

@ -0,0 +1,122 @@
from celery.utils.log import get_task_logger
from django.conf import settings
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
from .client import ChatopsProxyAPIClient, ChatopsProxyAPIException
task_logger = get_task_logger(__name__)
@shared_dedicated_queue_retry_task(
autoretry_for=(Exception,),
retry_backoff=True,
max_retries=100,
)
def register_oncall_tenant_async(**kwargs):
service_tenant_id = kwargs.get("service_tenant_id")
cluster_slug = kwargs.get("cluster_slug")
service_type = kwargs.get("service_type")
stack_id = kwargs.get("stack_id")
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
client.register_tenant(service_tenant_id, cluster_slug, service_type, stack_id)
except ChatopsProxyAPIException as api_exc:
task_logger.error(
f'msg="Failed to register OnCall tenant: {api_exc.msg}" service_tenant_id={service_tenant_id} cluster_slug={cluster_slug}'
)
if api_exc.status == 409:
# 409 Indicates that it's impossible to register tenant, because tenant already registered.
# Not retrying in this case, because manual conflict-resolution needed.
return
else:
# Otherwise keep retrying task
raise api_exc
except Exception as e:
# Keep retrying task for any other exceptions too
task_logger.error(
f"Failed to register OnCall tenant: {e} service_tenant_id={service_tenant_id} cluster_slug={cluster_slug}"
)
raise e
@shared_dedicated_queue_retry_task(
autoretry_for=(Exception,),
retry_backoff=True,
max_retries=100,
)
def unregister_oncall_tenant_async(**kwargs):
service_tenant_id = kwargs.get("service_tenant_id")
cluster_slug = kwargs.get("cluster_slug")
service_type = kwargs.get("service_type")
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
client.unregister_tenant(service_tenant_id, cluster_slug, service_type)
except ChatopsProxyAPIException as api_exc:
if api_exc.status == 400:
# 400 Indicates that tenant is already deleted
return
else:
# Otherwise keep retrying task
raise api_exc
except Exception as e:
task_logger.error(f"Failed to delete OnCallTenant: {e} service_tenant_id={service_tenant_id}")
raise e
@shared_dedicated_queue_retry_task(
autoretry_for=(Exception,),
retry_backoff=True,
max_retries=100,
)
def link_slack_team_async(**kwargs):
service_tenant_id = kwargs.get("service_tenant_id")
service_type = kwargs.get("service_type")
slack_team_id = kwargs.get("slack_team_id")
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
client.link_slack_team(service_tenant_id, slack_team_id, service_type)
except ChatopsProxyAPIException as api_exc:
task_logger.error(
f'msg="Failed to link slack team: {api_exc.msg}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}'
)
if api_exc.status == 409:
# Impossible to register tenant, slack workspace already connected to another cluster.
# Not retrying in this case, because manual conflict-resolution needed.
return
else:
raise api_exc
except Exception as e:
task_logger.error(
f'msg="Failed to link slack team: {e}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}'
)
raise e
@shared_dedicated_queue_retry_task(
autoretry_for=(Exception,),
retry_backoff=True,
max_retries=100,
)
def unlink_slack_team_async(**kwargs):
service_tenant_id = kwargs.get("service_tenant_id")
service_type = kwargs.get("service_type")
slack_team_id = kwargs.get("slack_team_id")
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
client.unlink_slack_team(service_tenant_id, slack_team_id, service_type)
except ChatopsProxyAPIException as api_exc:
if api_exc.status == 400:
# 400 Indicates that tenant is already deleted
return
else:
# Otherwise keep retrying task
raise api_exc
except Exception as e:
task_logger.error(
f'msg="Failed to unlink slack_team: {e}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}'
)
raise e

View file

@ -0,0 +1,66 @@
from unittest.mock import patch
import pytest
from django.test import override_settings
from apps.chatops_proxy.events import ChatopsEventsHandler
from apps.chatops_proxy.events.handlers import SlackInstallationHandler
from common.constants.slack_auth import SLACK_OAUTH_ACCESS_RESPONSE
installation_event = {
"event_type": "integration_installed",
"data": {
"provider_type": "slack",
"stack_id": "stack_id",
"grafana_user_id": "grafana_user_id",
"payload": SLACK_OAUTH_ACCESS_RESPONSE,
},
}
unknown_event = {
"event_type": "unknown_event",
"data": {
"provider_type": "slack",
"stack_id": "stack_id",
"grafana_user_id": "grafana_user_id",
"payload": {},
},
}
invalid_schema_event = {
"a": "b",
"c": "d",
}
@patch.object(ChatopsEventsHandler, "_exec", return_value=None)
@pytest.mark.parametrize(
"payload,is_handled",
[
(installation_event, True),
(unknown_event, False),
(invalid_schema_event, False),
],
)
@pytest.mark.django_db
@override_settings(UNIFIED_SLACK_APP_ENABLED=True)
def test_root_event_handler(mock_exec, payload, is_handled):
h = ChatopsEventsHandler()
assert h.handle(payload) is is_handled
@patch("apps.chatops_proxy.events.handlers.install_slack_integration", return_value=None)
@pytest.mark.django_db
def test_slack_installation_handler(mock_install_slack_integration, make_organization_and_user):
organization, user = make_organization_and_user()
installation_event["data"].update({"stack_id": organization.stack_id, "grafana_user_id": user.user_id})
h = SlackInstallationHandler()
assert h.match(unknown_event) is False
assert h.match(invalid_schema_event) is False
assert h.match(installation_event) is True
h.handle(installation_event["data"])
assert mock_install_slack_integration.call_args.args == (organization, user, installation_event["data"]["payload"])

View file

@ -0,0 +1,9 @@
from common.api_helpers.optional_slash_router import optional_slash_path
from .views import ChatopsEventsView
app_name = "chatops-proxy"
urlpatterns = [
optional_slash_path("events", ChatopsEventsView.as_view(), name="events"),
]

View file

@ -0,0 +1,143 @@
"""
Set of utils to handle oncall and chatops-proxy interaction.
"""
import logging
import typing
from django.conf import settings
from .client import PROVIDER_TYPE_SLACK, SERVICE_TYPE_ONCALL, ChatopsProxyAPIClient, ChatopsProxyAPIException
from .tasks import (
link_slack_team_async,
register_oncall_tenant_async,
unlink_slack_team_async,
unregister_oncall_tenant_async,
)
logger = logging.getLogger(__name__)
def get_installation_link_from_chatops_proxy(user) -> typing.Optional[str]:
"""
get_installation_link_from_chatops_proxy fetches slack installation link from chatops proxy.
If there is no existing slack installation - if returns link, If slack already installed, it returns None.
"""
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
org = user.organization
try:
link, _ = client.get_slack_oauth_link(
org.stack_id,
user.user_id,
org.web_link,
SERVICE_TYPE_ONCALL,
)
return link
except ChatopsProxyAPIException as api_exc:
if api_exc.status == 409:
return None
logger.exception(
"Error while getting installation link from chatops proxy: " "error=%s",
api_exc,
)
raise api_exc
def get_slack_oauth_response_from_chatops_proxy(stack_id) -> dict:
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
slack_installation, _ = client.get_oauth_installation(stack_id, PROVIDER_TYPE_SLACK)
return slack_installation.oauth_response
def register_oncall_tenant(service_tenant_id: str, cluster_slug: str, stack_id: int):
"""
register_oncall_tenant tries to register oncall tenant synchronously and fall back to task in case of any exceptions
to make sure that tenant is registered.
First attempt is synchronous to register tenant ASAP to not miss any chatops requests.
"""
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
client.register_tenant(
service_tenant_id,
cluster_slug,
SERVICE_TYPE_ONCALL,
stack_id,
)
except Exception as e:
logger.error(
f"create_oncall_connector: failed "
f"oncall_org_id={service_tenant_id} backend={cluster_slug} stack_id={stack_id} exc={e}"
)
register_oncall_tenant_async.apply_async(
kwargs={
"service_tenant_id": service_tenant_id,
"cluster_slug": cluster_slug,
"service_type": SERVICE_TYPE_ONCALL,
"stack_id": stack_id,
},
countdown=2,
)
def unregister_oncall_tenant(service_tenant_id: str, cluster_slug: str):
"""
unregister_oncall_tenant unregisters tenant asynchronously.
"""
unregister_oncall_tenant_async.apply_async(
kwargs={
"service_tenant_id": service_tenant_id,
"cluster_slug": cluster_slug,
"service_type": SERVICE_TYPE_ONCALL,
},
countdown=2,
)
def can_link_slack_team(
service_tenant_id: str,
slack_team_id: str,
cluster_slug: str,
) -> bool:
"""
can_link_slack_team checks if it's possible to link slack workspace to oncall tenant located in cluster.
All oncall tenants linked to same slack team should have same cluster.
"""
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
response = client.can_slack_link(service_tenant_id, cluster_slug, slack_team_id, SERVICE_TYPE_ONCALL)
return response.status_code == 200
except Exception as e:
logger.error(
f"can_link_slack_team: slack installation impossible: {e} "
f"service_tenant_id={service_tenant_id} slack_team_id={slack_team_id} cluster_slug={cluster_slug}"
)
return False
def link_slack_team(service_tenant_id: str, slack_team_id: str):
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
client.link_slack_team(service_tenant_id, slack_team_id, SERVICE_TYPE_ONCALL)
except Exception as e:
logger.error(
f'msg="Failed to link slack team: {e}"'
f"service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}"
)
link_slack_team_async.apply_async(
kwargs={
"service_tenant_id": service_tenant_id,
"slack_team_id": slack_team_id,
"service_type": SERVICE_TYPE_ONCALL,
},
countdown=2,
)
def unlink_slack_team(service_tenant_id: str, slack_team_id: str):
unlink_slack_team_async.apply_async(
kwargs={
"service_tenant_id": service_tenant_id,
"slack_team_id": slack_team_id,
"service_type": SERVICE_TYPE_ONCALL,
}
)

View file

@ -0,0 +1,24 @@
import logging
from django.conf import settings
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.chatops_proxy.events import ChatopsEventsHandler
from apps.chatops_proxy.events.signature import verify_signature
logger = logging.getLogger(__name__)
handler = ChatopsEventsHandler()
class ChatopsEventsView(APIView):
def post(self, request):
verified = verify_signature(request, settings.CHATOPS_SIGNING_SECRET)
if not verified:
logger.error("ChatopsEventsView: Invalid signature")
return Response(status=401)
found = handler.handle(request.data)
if not found:
return Response(status=400)
return Response(status=200)

View file

@ -0,0 +1,18 @@
import json
import typing
# ProxyMeta is a data injected into various Slack payloads to route them to the correct cluster via Chatops-Proxy
# Short keys are used to satisfy slack limit for 155 chars in values
class ProxyMeta(typing.TypedDict):
s: str # s is a service name
tid: str # tid is a tenant_id
def make_value(data: dict, organization) -> str:
# Slack block elements allow to pass value as string only (max 2000 chars)
return json.dumps({**data, "s": "oncall", "tid": str(organization.uuid)})
def make_private_metadata(data: dict, organization) -> str:
return json.dumps({**data, "s": "oncall", "tid": str(organization.uuid)})

View file

@ -0,0 +1,74 @@
import logging
from django.conf import settings
from apps.chatops_proxy.utils import unlink_slack_team
from apps.slack.tasks import (
clean_slack_integration_leftovers,
populate_slack_channels_for_team,
populate_slack_usergroups_for_team,
unpopulate_slack_user_identities,
)
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
logger = logging.getLogger(__name__)
class SlackInstallationExc(Exception):
"""
SlackInstallationExc represents some exception happened while managing Slack integration.
"""
def __init__(self, error_message=None):
# error message is a user-visible error message
self.error_message = error_message
def install_slack_integration(organization, user, oauth_response):
"""
Installs Slack integration for the organization.
Raises:
SlackInstallationExc if organization already has Slack integration.
"""
from apps.slack.models import SlackTeamIdentity
if organization.slack_team_identity is not None:
raise SlackInstallationExc("Organization already has Slack integration")
slack_team_id = oauth_response["team"]["id"]
slack_team_identity, is_slack_team_identity_created = SlackTeamIdentity.objects.get_or_create(
slack_id=slack_team_id,
)
# update slack oauth fields by data from response
slack_team_identity.update_oauth_fields(user, organization, oauth_response)
write_chatops_insight_log(
author=user, event_name=ChatOpsEvent.WORKSPACE_CONNECTED, chatops_type=ChatOpsTypePlug.SLACK.value
)
populate_slack_channels_for_team.apply_async((slack_team_identity.pk,))
user.slack_user_identity.update_profile_info()
# todo slack: do we need update info for all existing slack users in slack team?
# 24.03.2024 - this todo here for a while. populate_slack_user_identities automatically links users to slack.
# Should be useful if we want to unify with Incident.
# populate_slack_user_identities.apply_async((organization.pk,))
populate_slack_usergroups_for_team.apply_async((slack_team_identity.pk,), countdown=10)
def uninstall_slack_integration(organization, user):
"""
Uninstalls Slack integration for the organization.
Raises:
SlackInstallationExc if organization has no Slack integration.
"""
slack_team_identity = organization.slack_team_identity
if slack_team_identity is not None:
clean_slack_integration_leftovers.apply_async((organization.pk,))
if settings.FEATURE_MULTIREGION_ENABLED and not settings.UNIFIED_SLACK_APP_ENABLED:
unlink_slack_team(str(organization.uuid), slack_team_identity.slack_id)
write_chatops_insight_log(
author=user,
event_name=ChatOpsEvent.WORKSPACE_DISCONNECTED,
chatops_type=ChatOpsTypePlug.SLACK.value,
)
unpopulate_slack_user_identities(organization.pk, True)
else:
raise SlackInstallationExc("Organization has no Slack integration.")

View file

@ -14,7 +14,6 @@ from apps.slack.errors import (
SlackAPITokenError,
)
from apps.user_management.models.user import User
from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
if typing.TYPE_CHECKING:
from django.db.models.manager import RelatedManager
@ -55,30 +54,27 @@ class SlackTeamIdentity(models.Model):
def __str__(self):
return f"{self.pk}: {self.name}"
def update_oauth_fields(self, user, organization, reinstall_data):
def update_oauth_fields(self, user, organization, oauth_response):
logger.info(f"updated oauth_fields for sti {self.pk}")
from apps.slack.models import SlackUserIdentity
organization.slack_team_identity = self
organization.save(update_fields=["slack_team_identity"])
slack_user_identity, _ = SlackUserIdentity.objects.get_or_create(
slack_id=reinstall_data["authed_user"]["id"],
slack_id=oauth_response["authed_user"]["id"],
slack_team_identity=self,
)
user.slack_user_identity = slack_user_identity
user.save(update_fields=["slack_user_identity"])
self.bot_access_token = reinstall_data["access_token"]
self.bot_user_id = reinstall_data["bot_user_id"]
self.oauth_scope = reinstall_data["scope"]
self.cached_name = reinstall_data["team"]["name"]
self.access_token = reinstall_data["authed_user"]["access_token"]
self.bot_access_token = oauth_response["access_token"]
self.bot_user_id = oauth_response["bot_user_id"]
self.oauth_scope = oauth_response["scope"]
self.cached_name = oauth_response["team"]["name"]
self.access_token = oauth_response["authed_user"]["access_token"]
self.installed_by = slack_user_identity
self.cached_reinstall_data = None
self.installed_via_granular_permissions = True
self.save()
write_chatops_insight_log(
author=user, event_name=ChatOpsEvent.WORKSPACE_CONNECTED, chatops_type=ChatOpsTypePlug.SLACK.value
)
def get_cached_channels(self, search_term=None, slack_id=None):
queryset = self.cached_channels

View file

@ -2,6 +2,7 @@ import json
import typing
from apps.api.permissions import RBACPermission
from apps.slack.chatops_proxy_routing import make_private_metadata
from apps.slack.scenarios import scenario_step
from apps.slack.types import (
Block,
@ -63,7 +64,7 @@ class OpenAlertAppearanceDialogStep(AlertGroupActionsMixin, scenario_step.Scenar
"type": "plain_text",
"text": "Refresh alert group",
},
"private_metadata": json.dumps(private_metadata),
"private_metadata": make_private_metadata(private_metadata, alert_receive_channel.organization),
}
self._slack_client.views_open(trigger_id=payload["trigger_id"], view=view)

View file

@ -11,6 +11,7 @@ from apps.alerts.incident_appearance.renderers.constants import DEFAULT_BACKUP_T
from apps.alerts.incident_appearance.renderers.slack_renderer import AlertSlackRenderer
from apps.alerts.models import Alert, AlertGroup, AlertGroupLogRecord, AlertReceiveChannel, Invitation
from apps.api.permissions import RBACPermission
from apps.slack.chatops_proxy_routing import make_private_metadata, make_value
from apps.slack.constants import CACHE_UPDATE_INCIDENT_SLACK_MESSAGE_LIFETIME
from apps.slack.errors import (
SlackAPICantUpdateMessageError,
@ -339,11 +340,12 @@ class SelectAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
"type": "plain_text",
"text": "Attach to Alert Group",
},
"private_metadata": json.dumps(
"private_metadata": make_private_metadata(
{
"organization_id": self.organization.pk if self.organization else alert_group.organization.pk,
"alert_group_pk": alert_group.pk,
}
},
self.organization,
),
"close": {"type": "plain_text", "text": "Cancel", "emoji": True},
}
@ -398,9 +400,8 @@ class SelectAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
collected_options: typing.List[CompositionObjectOption] = []
blocks: Block.AnyBlocks = []
alert_receive_channel_ids = AlertReceiveChannel.objects.filter(
organization=alert_group.channel.organization
).values_list("id", flat=True)
org = alert_group.channel.organization
alert_receive_channel_ids = AlertReceiveChannel.objects.filter(organization=org).values_list("id", flat=True)
alert_groups_queryset = (
AlertGroup.objects.prefetch_related(
@ -412,6 +413,7 @@ class SelectAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
.order_by("-pk")
)
sf = SlackFormatter(org)
for alert_group_to_attach in alert_groups_queryset[:ATTACH_TO_ALERT_GROUPS_LIMIT]:
# long_verbose_name_without_formatting was removed from here because it increases queries count due to
# alerts.first().
@ -419,7 +421,6 @@ class SelectAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
# prefetch_related.
first_alert = alert_group_to_attach.alerts.all()[0]
templated_alert = AlertSlackRenderer(first_alert).templated_alert
sf = SlackFormatter(alert_group_to_attach.channel.organization)
if is_string_with_visible_characters(templated_alert.title):
alert_name = templated_alert.title
alert_name = sf.format(alert_name)
@ -434,7 +435,7 @@ class SelectAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
collected_options.append(
{
"text": {"type": "plain_text", "text": f"{alert_name}", "emoji": True},
"value": str(alert_group_to_attach.pk),
"value": make_value({root_ag_id_value_key: alert_group_to_attach.pk}, org),
}
)
if len(collected_options) > 0:
@ -495,16 +496,22 @@ class AttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
if payload["type"] == PayloadType.VIEW_SUBMISSION:
alert_group_pk = json.loads(payload["view"]["private_metadata"])["alert_group_pk"]
alert_group = AlertGroup.objects.get(pk=alert_group_pk)
root_alert_group_pk = payload["view"]["state"]["values"][SelectAttachGroupStep.routing_uid()][
AttachGroupStep.routing_uid()
]["selected_option"]["value"]
root_alert_group_pk = _get_root_alert_group_id_from_value(
payload["view"]["state"]["values"][SelectAttachGroupStep.routing_uid()][AttachGroupStep.routing_uid()][
"selected_option"
]["value"]
)
root_alert_group = AlertGroup.objects.get(pk=root_alert_group_pk)
# old version of attach selection by dropdown
else:
try:
root_alert_group_pk = int(payload["actions"][0]["selected_options"][0]["value"])
root_alert_group_pk = int(
_get_root_alert_group_id_from_value(payload["actions"][0]["selected_options"][0]["value"])
)
except KeyError:
root_alert_group_pk = int(payload["actions"][0]["selected_option"]["value"])
root_alert_group_pk = int(
_get_root_alert_group_id_from_value(payload["actions"][0]["selected_option"]["value"])
)
root_alert_group = AlertGroup.objects.get(pk=root_alert_group_pk)
alert_group = self.get_alert_group(slack_team_identity, payload)
@ -512,6 +519,22 @@ class AttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
alert_group.attach_by_user(self.user, root_alert_group, action_source=ActionSource.SLACK)
root_ag_id_value_key = "ag_id"
def _get_root_alert_group_id_from_value(value: str) -> str:
"""
Extract ag ID from value string.
It might be either JSON-encoded object or just a user ID.
Json encoded object introduced for Chatops-Proxy routing, plain string with user ID is legacy.
"""
try:
data = json.loads(value)
return data[root_ag_id_value_key]
except json.JSONDecodeError:
return value
class UnAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE]
@ -717,8 +740,14 @@ class AcknowledgeConfirmationStep(AcknowledgeGroupStep):
) -> None:
from apps.alerts.models import AlertGroup
alert_group_id = payload["actions"][0]["value"].split("_")[1]
alert_group = AlertGroup.objects.get(pk=alert_group_id)
value = payload["actions"][0]["value"]
try:
alert_group_pk = json.loads(value)["alert_group_pk"]
except json.JSONDecodeError:
# Deprecated and kept for backward compatibility (so older Slack messages can still be processed)
alert_group_pk = value.split("_")[1]
alert_group = AlertGroup.objects.get(pk=alert_group_pk)
channel = payload["channel"]["id"]
message_ts = payload["message_ts"]
@ -776,10 +805,7 @@ class AcknowledgeConfirmationStep(AcknowledgeGroupStep):
"text": "Confirm",
"type": "button",
"style": "primary",
"value": scenario_step.ScenarioStep.get_step(
"distribute_alerts", "AcknowledgeConfirmationStep"
).routing_uid()
+ ("_" + str(alert_group.pk)),
"value": make_value({"alert_group_pk": alert_group.pk}, alert_group.channel.organization),
},
],
}

View file

@ -2,6 +2,7 @@ import json
import typing
from apps.alerts.paging import DirectPagingAlertGroupResolvedError, direct_paging, unpage_user, user_is_oncall
from apps.slack.chatops_proxy_routing import make_private_metadata, make_value
from apps.slack.constants import DIVIDER
from apps.slack.scenarios import scenario_step
from apps.slack.scenarios.paging import (
@ -61,7 +62,9 @@ class ManageRespondersUserChange(scenario_step.ScenarioStep):
# check if user is on-call
if not user_is_oncall(selected_user):
# display additional confirmation modal
private_metadata = json.dumps({USER_DATA_KEY: selected_user.id, ALERT_GROUP_DATA_KEY: alert_group.pk})
private_metadata = make_private_metadata(
{USER_DATA_KEY: selected_user.id, ALERT_GROUP_DATA_KEY: alert_group.pk}, organization
)
view = _display_confirm_participant_invitation_view(
ManageRespondersConfirmUserChange.routing_uid(), private_metadata
)
@ -163,7 +166,7 @@ def render_dialog(alert_group: "AlertGroup", alert_group_resolved_warning=False)
"type": "button",
"text": {"type": "plain_text", "text": "Remove", "emoji": True},
"action_id": ManageRespondersRemoveUser.routing_uid(),
"value": str(user["id"]),
"value": make_value({"id": str(user["id"])}, alert_group.channel.organization),
},
},
),
@ -205,7 +208,7 @@ def _get_selected_user_from_payload(payload: EventPayload) -> "User":
from apps.user_management.models import User
try:
selected_user_id = payload["actions"][0]["value"] # "remove" button
selected_user_id = json.loads(payload["actions"][0]["value"])["id"] # "remove" button
except KeyError:
try:
# "confirm" button on availability warnings modal

View file

@ -11,6 +11,7 @@ from apps.alerts.models import AlertReceiveChannel
from apps.alerts.paging import DirectPagingUserTeamValidationError, UserNotifications, direct_paging, user_is_oncall
from apps.api.permissions import RBACPermission, user_is_authorized
from apps.schedules.ical_utils import get_cached_oncall_users_for_multiple_schedules
from apps.slack.chatops_proxy_routing import make_private_metadata, make_value
from apps.slack.constants import DIVIDER, PRIVATE_METADATA_MAX_LENGTH
from apps.slack.errors import SlackAPIChannelNotFoundError
from apps.slack.scenarios import scenario_step
@ -292,15 +293,16 @@ class OnPagingUserChange(scenario_step.ScenarioStep):
# check if user is on-call
if not user_is_oncall(selected_user):
# display additional confirmation modal
metadata = metadata = json.loads(payload["view"]["private_metadata"])
private_metadata = json.dumps(
metadata = json.loads(payload["view"]["private_metadata"])
private_metadata = make_private_metadata(
{
"state": payload["view"]["state"],
"input_id_prefix": metadata["input_id_prefix"],
"channel_id": metadata["channel_id"],
"submit_routing_uid": metadata["submit_routing_uid"],
DataKey.USERS: metadata[DataKey.USERS],
}
},
selected_user.organization,
)
view = _display_confirm_participant_invitation_view(
@ -326,9 +328,9 @@ class OnPagingUserChange(scenario_step.ScenarioStep):
class OnPagingItemActionChange(scenario_step.ScenarioStep):
"""Reload form with updated user details."""
def _parse_action(self, payload: EventPayload) -> typing.Tuple[Policy, str, str]:
value = payload["actions"][0]["selected_option"]["value"]
return value.split("|")
def _parse_action(self, payload: EventPayload) -> typing.Tuple[Policy, DataKey, str]:
value = json.loads(payload["actions"][0]["selected_option"]["value"])
return value["action"], value["key"], value["id"]
def process_scenario(
self,
@ -464,7 +466,9 @@ def render_dialog(
}
)
return _get_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata))
return _get_form_view(
submit_routing_uid, blocks, make_private_metadata(new_private_metadata, selected_organization)
)
def _get_unauthorized_warning(error=False):
@ -518,7 +522,7 @@ def _get_organization_select(
"text": f"{org.org_title} ({org.stack_slug})",
"emoji": True,
},
"value": f"{org.pk}",
"value": make_value({"id": org.pk}, org),
}
)
@ -547,7 +551,7 @@ def _get_select_field_value(payload: EventPayload, prefix_id: str, routing_uid:
field = payload["view"]["state"]["values"][prefix_id + field_id][routing_uid]["selected_option"]
except KeyError:
return None
return field["value"] if field else None
return json.loads(field["value"])["id"] if field else None
def _get_selected_org_from_payload(
@ -616,7 +620,7 @@ def _get_team_select_blocks(
"text": team_name,
"emoji": True,
},
"value": str(team_pk),
"value": make_value({"id": team_pk}, organization),
}
)
@ -668,7 +672,7 @@ def _get_team_select_blocks(
def _create_user_option_groups(
users: "RelatedManager['User']", max_options_per_group: int, option_group_label_text_prefix: str
organization, users: "RelatedManager['User']", max_options_per_group: int, option_group_label_text_prefix: str
) -> typing.List[CompositionObjectOptionGroup]:
user_options: typing.List[CompositionObjectOption] = [
{
@ -677,7 +681,7 @@ def _create_user_option_groups(
"text": f"{user.name or user.username}",
"emoji": True,
},
"value": f"{user.pk}",
"value": make_value({"id": user.pk}, organization),
}
for user in users
]
@ -733,7 +737,7 @@ def _get_user_select_blocks(
# selected items
if selected_users := get_current_items(payload, DataKey.USERS, organization.users):
blocks += [DIVIDER]
blocks += _get_selected_entries_list(input_id_prefix, DataKey.USERS, selected_users)
blocks += _get_selected_entries_list(organization, input_id_prefix, DataKey.USERS, selected_users)
blocks += [DIVIDER]
return blocks
@ -746,10 +750,10 @@ def _get_users_select(
oncall_user_pks = {user.pk for _, users in schedules.items() for user in users}
oncall_user_option_groups = _create_user_option_groups(
organization.users.filter(pk__in=oncall_user_pks), max_options_per_group, "On-call now"
organization, organization.users.filter(pk__in=oncall_user_pks), max_options_per_group, "On-call now"
)
not_oncall_user_option_groups = _create_user_option_groups(
organization.users.exclude(pk__in=oncall_user_pks), max_options_per_group, "Not on-call"
organization, organization.users.exclude(pk__in=oncall_user_pks), max_options_per_group, "Not on-call"
)
if not oncall_user_option_groups and not not_oncall_user_option_groups:
@ -773,7 +777,7 @@ def _get_users_select(
def _get_selected_entries_list(
input_id_prefix: str, key: DataKey, entries: typing.List[typing.Tuple[Model, Policy]]
organization: "Organization", input_id_prefix: str, key: DataKey, entries: typing.List[typing.Tuple[Model, Policy]]
) -> typing.List[Block.Section]:
current_entries: typing.List[Block.Section] = []
for entry, policy in entries:
@ -793,7 +797,10 @@ def _get_selected_entries_list(
"accessory": {
"type": "overflow",
"options": [
{"text": {"type": "plain_text", "text": f"{label}"}, "value": f"{action}|{key}|{entry.pk}"}
{
"text": {"type": "plain_text", "text": f"{label}"},
"value": make_value({"action": action, "key": key, "id": str(entry.pk)}, organization),
}
for (action, label) in ITEM_ACTIONS
],
"action_id": OnPagingItemActionChange.routing_uid(),
@ -900,7 +907,7 @@ def _get_available_organizations(
def _generate_input_id_prefix() -> str:
"""
returns unique string to not to preserve input's values between view update
returns unique string to not preserve input's values between view update
https://api.slack.com/methods/views.update#markdown
"""

View file

@ -7,6 +7,7 @@ from django.db.models import Q
from django.utils.text import Truncator
from apps.api.permissions import RBACPermission
from apps.slack.chatops_proxy_routing import make_value
from apps.slack.constants import BLOCK_SECTION_TEXT_MAX_SIZE, DIVIDER
from apps.slack.errors import (
SlackAPICantUpdateMessageError,
@ -483,14 +484,15 @@ class ResolutionNoteModalStep(AlertGroupActionsMixin, scenario_step.ScenarioStep
"emoji": True,
},
"action_id": AddRemoveThreadMessageStep.routing_uid(),
"value": json.dumps(
"value": make_value(
{
"resolution_note_window_action": "edit",
"msg_value": "add" if not message.added_to_resolution_note else "remove",
"message_pk": message.pk,
"resolution_note_pk": None,
"alert_group_pk": alert_group.pk,
}
},
alert_group.channel.organization,
),
},
}
@ -542,7 +544,7 @@ class ResolutionNoteModalStep(AlertGroupActionsMixin, scenario_step.ScenarioStep
"emoji": True,
},
"action_id": AddRemoveThreadMessageStep.routing_uid(),
"value": json.dumps(
"value": make_value(
{
"resolution_note_window_action": "edit",
"msg_value": "remove",
@ -551,7 +553,8 @@ class ResolutionNoteModalStep(AlertGroupActionsMixin, scenario_step.ScenarioStep
else resolution_note_slack_message.pk,
"resolution_note_pk": resolution_note.pk,
"alert_group_pk": alert_group.pk,
}
},
alert_group.channel.organization,
),
"confirm": {
"title": {"type": "plain_text", "text": "Are you sure?"},

View file

@ -4,6 +4,7 @@ import typing
import pytz
from apps.schedules.models import OnCallSchedule
from apps.slack.chatops_proxy_routing import make_value
from apps.slack.scenarios import scenario_step
from apps.slack.types import (
Block,
@ -33,22 +34,26 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
slack_team_identity: "SlackTeamIdentity",
payload: EventPayload,
) -> None:
if payload["actions"][0].get("value", None) and payload["actions"][0]["value"].startswith("edit"):
action_type = payload["actions"][0]["type"]
if action_type == BlockActionType.BUTTON:
self.open_settings_modal(payload)
elif payload["actions"][0].get("type", None) and payload["actions"][0]["type"] == "static_select":
elif action_type == BlockActionType.STATIC_SELECT:
self.set_selected_value(slack_user_identity, payload)
def open_settings_modal(self, payload: EventPayload) -> None:
schedule_id = payload["actions"][0]["value"].split("_")[1]
value = payload["actions"][0]["value"]
try:
_ = OnCallSchedule.objects.get(pk=schedule_id) # noqa
schedule_id = json.loads(value)["schedule_id"]
except json.JSONDecodeError:
# Deprecated and kept for backward compatibility (so older Slack messages can still be processed)
schedule_id = value.split("_")[1]
try:
schedule = OnCallSchedule.objects.get(pk=schedule_id) # noqa
except OnCallSchedule.DoesNotExist:
blocks = [{"type": "section", "text": {"type": "plain_text", "text": "Schedule was removed"}}]
else:
blocks = self.get_modal_blocks(schedule_id)
private_metadata = {}
private_metadata["schedule_id"] = schedule_id
blocks = self.get_modal_blocks(schedule)
view: ModalView = {
"callback_id": EditScheduleShiftNotifyStep.routing_uid(),
@ -58,7 +63,7 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
"type": "plain_text",
"text": "Notification preferences",
},
"private_metadata": json.dumps(private_metadata),
"private_metadata": json.dumps({"schedule_id": schedule_id}),
}
self._slack_client.views_open(trigger_id=payload["trigger_id"], view=view)
@ -69,7 +74,7 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
schedule_id = private_metadata["schedule_id"]
schedule = OnCallSchedule.objects.get(pk=schedule_id)
prev_state = schedule.insight_logs_serialized
setattr(schedule, action["block_id"], int(action["selected_option"]["value"]))
setattr(schedule, action["block_id"], json.loads(action["selected_option"]["value"])["option"])
schedule.save()
new_state = schedule.insight_logs_serialized
write_resource_insight_log(
@ -80,7 +85,7 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
new_state=new_state,
)
def get_modal_blocks(self, schedule_id: str) -> typing.List[Block.Section]:
def get_modal_blocks(self, schedule: OnCallSchedule) -> typing.List[Block.Section]:
blocks: typing.List[Block.Section] = [
{
"type": "section",
@ -90,8 +95,8 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
"type": "static_select",
"placeholder": {"type": "plain_text", "text": "----"},
"action_id": EditScheduleShiftNotifyStep.routing_uid(),
"options": self.get_options("notify_oncall_shift_freq"),
"initial_option": self.get_initial_option(schedule_id, "notify_oncall_shift_freq"),
"options": self.get_options(schedule, "notify_oncall_shift_freq"),
"initial_option": self.get_initial_option(schedule, "notify_oncall_shift_freq"),
},
},
{
@ -102,8 +107,8 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
"type": "static_select",
"placeholder": {"type": "plain_text", "text": "----"},
"action_id": EditScheduleShiftNotifyStep.routing_uid(),
"options": self.get_options("mention_oncall_start"),
"initial_option": self.get_initial_option(schedule_id, "mention_oncall_start"),
"options": self.get_options(schedule, "mention_oncall_start"),
"initial_option": self.get_initial_option(schedule, "mention_oncall_start"),
},
},
{
@ -114,8 +119,8 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
"type": "static_select",
"placeholder": {"type": "plain_text", "text": "----"},
"action_id": EditScheduleShiftNotifyStep.routing_uid(),
"options": self.get_options("mention_oncall_next"),
"initial_option": self.get_initial_option(schedule_id, "mention_oncall_next"),
"options": self.get_options(schedule, "mention_oncall_next"),
"initial_option": self.get_initial_option(schedule, "mention_oncall_next"),
},
},
{
@ -126,24 +131,25 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
"type": "static_select",
"placeholder": {"type": "plain_text", "text": "----"},
"action_id": EditScheduleShiftNotifyStep.routing_uid(),
"options": self.get_options("notify_empty_oncall"),
"initial_option": self.get_initial_option(schedule_id, "notify_empty_oncall"),
"options": self.get_options(schedule, "notify_empty_oncall"),
"initial_option": self.get_initial_option(schedule, "notify_empty_oncall"),
},
},
]
return blocks
def get_options(self, select_name: str) -> typing.List[CompositionObjectOption]:
def get_options(self, schedule: OnCallSchedule, select_name: str) -> typing.List[CompositionObjectOption]:
select_options = getattr(self, f"{select_name}_options")
return [
{"text": {"type": "plain_text", "text": select_options[option]}, "value": str(option)}
{
"text": {"type": "plain_text", "text": select_options[option]},
"value": make_value({"option": option}, schedule.organization),
}
for option in select_options
]
def get_initial_option(self, schedule_id: str, select_name: str) -> CompositionObjectOption:
schedule = OnCallSchedule.objects.get(pk=schedule_id)
def get_initial_option(self, schedule: OnCallSchedule, select_name: str) -> CompositionObjectOption:
current_value = getattr(schedule, select_name)
text = getattr(self, f"{select_name}_options")[current_value]
@ -152,7 +158,7 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
"type": "plain_text",
"text": f"{text}",
},
"value": str(int(current_value)),
"value": make_value({"option": int(current_value)}, schedule.organization),
}
return initial_option
@ -243,7 +249,7 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
"type": "button",
"action_id": f"{cls.routing_uid()}",
"text": {"type": "plain_text", "text": ":gear:", "emoji": True},
"value": f"edit_{schedule.pk}",
"value": make_value({"schedule_id": schedule.pk}, schedule.organization),
},
],
},

View file

@ -6,6 +6,7 @@ import humanize
from django.utils import timezone
from apps.api.permissions import RBACPermission
from apps.slack.chatops_proxy_routing import make_value
from apps.slack.models import SlackMessage
from apps.slack.scenarios import scenario_step
from apps.slack.types import Block, BlockActionType, EventPayload, PayloadType, ScenarioRoute
@ -143,7 +144,7 @@ class BaseShiftSwapRequestStep(scenario_step.ScenarioStep):
"text": "Accept",
"emoji": True,
},
"value": json.dumps(value),
"value": make_value(value, shift_swap_request.organization),
"action_id": AcceptShiftSwapRequestStep.routing_uid(),
},
],

View file

@ -0,0 +1,135 @@
# Response example from Slack docs https://api.slack.com/methods/oauth.v2.access#examples
from unittest.mock import patch
import pytest
from apps.slack.client import SlackClient
from apps.slack.installation import SlackInstallationExc, install_slack_integration, uninstall_slack_integration
from common.constants.slack_auth import SLACK_OAUTH_ACCESS_RESPONSE
users_profile_get_response = {
"ok": True,
"user": {
"id": "W012A3CDE",
"team_id": "T012AB3C4",
"name": "spengler",
"deleted": False,
"color": "9f69e7",
"real_name": "Egon Spengler",
"tz": "America/Los_Angeles",
"tz_label": "Pacific Daylight Time",
"tz_offset": -25200,
"profile": {
"avatar_hash": "ge3b51ca72de",
"status_text": "Print is dead",
"status_emoji": ":books:",
"real_name": "Egon Spengler",
"display_name": "spengler",
"real_name_normalized": "Egon Spengler",
"display_name_normalized": "spengler",
"email": "spengler@ghostbusters.example.com",
"image_original": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg",
"image_24": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg",
"image_32": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg",
"image_48": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg",
"image_72": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg",
"image_192": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg",
"image_512": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg",
"team": "T012AB3C4",
},
"is_admin": True,
"is_owner": False,
"is_primary_owner": False,
"is_restricted": False,
"is_ultra_restricted": False,
"is_bot": False,
"updated": 1502138686,
"is_app_user": False,
"has_2fa": False,
},
}
@patch("apps.slack.tasks.populate_slack_channels_for_team.apply_async", return_value=None)
@patch("apps.slack.tasks.populate_slack_usergroups_for_team.apply_async", return_value=None)
@patch.object(SlackClient, "users_info", return_value=users_profile_get_response)
@pytest.mark.django_db
def test_install_slack_integration(
mock_populate_slack_channels_for_team,
mock_populate_slack_usergroups_for_team,
mock_users_info,
make_organization_and_user,
):
organization, user = make_organization_and_user()
install_slack_integration(organization, user, SLACK_OAUTH_ACCESS_RESPONSE)
assert organization.slack_team_identity is not None
# test that two most important fields are set: id of slack workspace and api acess token
assert organization.slack_team_identity.slack_id == SLACK_OAUTH_ACCESS_RESPONSE["team"]["id"]
assert organization.slack_team_identity.bot_access_token == SLACK_OAUTH_ACCESS_RESPONSE["access_token"]
# install_slack_integration links instgallers's slack profile to OnCall
assert user.slack_user_identity is not None
# assert that installer slack profile is linked to OnCall user
assert user.slack_user_identity.slack_id == SLACK_OAUTH_ACCESS_RESPONSE["authed_user"]["id"]
# assert that we populated user's profile info
assert user.slack_user_identity.cached_slack_login == users_profile_get_response["user"]["name"]
# assert that we ran task for fetching data from slack
assert mock_populate_slack_channels_for_team.called
assert mock_populate_slack_usergroups_for_team.called
@pytest.mark.django_db
def test_install_slack_integration_raises_exception_for_existing_integration(
make_organization_and_user, make_slack_team_identity
):
slack_team_identity = make_slack_team_identity()
organization, user = make_organization_and_user()
organization.slack_team_identity = slack_team_identity
organization.save()
with pytest.raises(SlackInstallationExc):
install_slack_integration(organization, user, SLACK_OAUTH_ACCESS_RESPONSE)
@patch("apps.slack.tasks.clean_slack_integration_leftovers.apply_async", return_value=None)
@pytest.mark.django_db
def test_uninstall_slack_integration(
mock_clean_slack_integration_leftovers,
make_organization_and_user,
make_slack_team_identity,
make_slack_user_identity,
):
slack_team_identity = make_slack_team_identity()
organization, user = make_organization_and_user()
organization.slack_team_identity = slack_team_identity
organization.save()
organization.refresh_from_db()
slack_user_identity = make_slack_user_identity(slack_team_identity=slack_team_identity)
user.slack_user_identity = slack_user_identity
user.save()
user.refresh_from_db()
uninstall_slack_integration(organization, user)
organization.refresh_from_db()
user.refresh_from_db()
assert organization.slack_team_identity is None
assert user.slack_user_identity is None
# assert that we ran task for fetching data from slack
assert mock_clean_slack_integration_leftovers.called
@pytest.mark.django_db
def test_uninstall_slack_integration_raises_exception_for_non_existing_integration(
make_organization_and_user,
):
organization, user = make_organization_and_user()
with pytest.raises(SlackInstallationExc):
uninstall_slack_integration(organization, user)

View file

@ -6,6 +6,7 @@ from django.utils import timezone
from apps.base.models import UserNotificationPolicy
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb
from apps.slack.chatops_proxy_routing import make_private_metadata, make_value
from apps.slack.scenarios.manage_responders import (
ALERT_GROUP_DATA_KEY,
DIRECT_PAGING_USER_SELECT_ID,
@ -23,17 +24,19 @@ CHANNEL_ID = "123"
MESSAGE_TS = "67"
def make_slack_payload(user=None, actions=None):
def make_slack_payload(organization, user=None, actions=None):
payload = {
"trigger_id": TRIGGER_ID,
"view": {
"id": "view-id",
"private_metadata": json.dumps({"input_id_prefix": "", ALERT_GROUP_DATA_KEY: ALERT_GROUP_ID}),
"private_metadata": make_private_metadata(
{"input_id_prefix": "", ALERT_GROUP_DATA_KEY: ALERT_GROUP_ID}, organization
),
"state": {
"values": {
DIRECT_PAGING_USER_SELECT_ID: {
ManageRespondersUserChange.routing_uid(): {
"selected_option": {"value": user.pk} if user else None
"selected_option": {"value": make_value({"id": user.pk}, organization)} if user else None
}
},
}
@ -118,21 +121,23 @@ def test_add_user_no_warning(manage_responders_setup, make_schedule, make_on_cal
notify_by=UserNotificationPolicy.NotificationChannel.SMS,
)
payload = make_slack_payload(user=user)
payload = make_slack_payload(organization, user=user)
step = ManageRespondersUserChange(slack_team_identity, organization, user)
with patch.object(step._slack_client, "views_update") as mock_slack_api_call:
step.process_scenario(slack_user_identity, slack_team_identity, payload)
# check there's a delete button for the user
assert mock_slack_api_call.call_args.kwargs["view"]["blocks"][0]["accessory"]["value"] == str(user.pk)
assert mock_slack_api_call.call_args.kwargs["view"]["blocks"][0]["accessory"]["value"] == make_value(
{"id": str(user.pk)}, organization
)
@pytest.mark.django_db
def test_add_user_raise_warning(manage_responders_setup):
organization, user, slack_team_identity, slack_user_identity = manage_responders_setup
# user is not on call
payload = make_slack_payload(user=user)
payload = make_slack_payload(organization, user=user)
step = ManageRespondersUserChange(slack_team_identity, organization, user)
with patch.object(step._slack_client, "views_push") as mock_slack_api_call:
@ -154,7 +159,7 @@ def test_add_user_raise_warning(manage_responders_setup):
def test_remove_user(manage_responders_setup):
organization, user, slack_team_identity, slack_user_identity = manage_responders_setup
payload = make_slack_payload(actions=[{"value": user.pk}])
payload = make_slack_payload(organization, actions=[{"value": make_value({"id": user.pk}, organization)}])
step = ManageRespondersRemoveUser(slack_team_identity, organization, user)
with patch.object(step._slack_client, "views_update") as mock_slack_api_call:
step.process_scenario(slack_user_identity, slack_team_identity, payload)

View file

@ -7,6 +7,7 @@ from django.utils import timezone
from apps.alerts.models import AlertReceiveChannel
from apps.api.permissions import LegacyAccessControlRole
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb
from apps.slack.chatops_proxy_routing import make_private_metadata, make_value
from apps.slack.scenarios.paging import (
DIRECT_PAGING_MESSAGE_INPUT_ID,
DIRECT_PAGING_ORG_SELECT_ID,
@ -32,24 +33,31 @@ def make_slack_payload(organization, team=None, user=None, current_users=None, a
"trigger_id": "111",
"view": {
"id": "view-id",
"private_metadata": json.dumps(
"private_metadata": make_private_metadata(
{
"input_id_prefix": "",
"channel_id": "123",
"submit_routing_uid": "FinishStepUID",
DataKey.USERS: current_users or {},
}
},
organization,
),
"state": {
"values": {
DIRECT_PAGING_ORG_SELECT_ID: {
OnPagingOrgChange.routing_uid(): {"selected_option": {"value": organization.pk}}
OnPagingOrgChange.routing_uid(): {
"selected_option": {"value": make_value({"id": organization.pk}, organization)}
}
},
DIRECT_PAGING_TEAM_SELECT_ID: {
OnPagingTeamChange.routing_uid(): {"selected_option": {"value": team.pk if team else None}}
OnPagingTeamChange.routing_uid(): {
"selected_option": {"value": make_value({"id": team.pk if team else None}, organization)}
}
},
DIRECT_PAGING_USER_SELECT_ID: {
OnPagingUserChange.routing_uid(): {"selected_option": {"value": user.pk} if user else None}
OnPagingUserChange.routing_uid(): {
"selected_option": {"value": make_value({"id": user.pk}, organization)} if user else None
}
},
DIRECT_PAGING_MESSAGE_INPUT_ID: {FinishDirectPaging.routing_uid(): {"value": "The Message"}},
}
@ -203,7 +211,13 @@ def test_change_user_policy(make_organization_and_user_with_slack_identities):
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
payload = make_slack_payload(
organization=organization,
actions=[{"selected_option": {"value": f"{Policy.IMPORTANT}|{DataKey.USERS}|{user.pk}"}}],
actions=[
{
"selected_option": {
"value": make_value({"action": Policy.IMPORTANT, "key": DataKey.USERS, "id": user.pk}, organization)
}
}
],
)
step = OnPagingItemActionChange(slack_team_identity)
@ -219,7 +233,15 @@ def test_remove_user(make_organization_and_user_with_slack_identities):
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
payload = make_slack_payload(
organization=organization,
actions=[{"selected_option": {"value": f"{Policy.REMOVE_ACTION}|{DataKey.USERS}|{user.pk}"}}],
actions=[
{
"selected_option": {
"value": make_value(
{"action": Policy.REMOVE_ACTION, "key": DataKey.USERS, "id": user.pk}, organization
)
}
}
],
)
step = OnPagingItemActionChange(slack_team_identity)
@ -302,7 +324,9 @@ def test_get_organization_select(make_organization):
select = _get_organization_select(Organization.objects.filter(pk=organization.pk), organization, "test")
assert len(select["element"]["options"]) == 1
assert select["element"]["options"][0]["value"] == str(organization.pk)
assert json.loads(select["element"]["options"][0]["value"]) == json.loads(
make_value({"id": organization.pk}, organization)
)
assert select["element"]["options"][0]["text"]["text"] == "Organization (stack_slug)"
@ -322,7 +346,10 @@ def test_get_team_select_blocks(
input_id_prefix = "nmxcnvmnxv"
def _contstruct_team_option(team):
return {"text": {"emoji": True, "text": team.name, "type": "plain_text"}, "value": str(team.pk)}
return {
"text": {"emoji": True, "text": team.name, "type": "plain_text"},
"value": make_value({"id": team.pk}, organization),
}
# no team selected - no team direct paging integrations available
organization, _, _, slack_user_identity = make_organization_and_user_with_slack_identities()
@ -408,5 +435,7 @@ def test_get_team_select_blocks(
assert input_block["type"] == "input"
assert len(input_block["element"]["options"]) == 1
assert input_block["element"]["options"] == [_contstruct_team_option(team)]
assert json.loads(input_block["element"]["options"][0]["value"]) == json.loads(
_contstruct_team_option(team)["value"]
)
assert context_block["elements"][0]["text"] == info_msg

View file

@ -3,6 +3,7 @@ from unittest.mock import patch
import pytest
from apps.slack.chatops_proxy_routing import make_value
from apps.slack.client import SlackClient
from apps.slack.constants import BLOCK_SECTION_TEXT_MAX_SIZE
from apps.slack.errors import SlackAPIViewNotFoundError
@ -91,14 +92,15 @@ def test_get_resolution_notes_blocks_non_empty(
"emoji": True,
},
"action_id": "AddRemoveThreadMessageStep",
"value": json.dumps(
"value": make_value(
{
"resolution_note_window_action": "edit",
"msg_value": "add",
"message_pk": resolution_note.pk,
"resolution_note_pk": None,
"alert_group_pk": alert_group.pk,
}
},
organization,
),
},
},
@ -281,14 +283,15 @@ def test_get_resolution_notes_blocks_latest_limit(
"emoji": True,
},
"action_id": "AddRemoveThreadMessageStep",
"value": json.dumps(
"value": make_value(
{
"resolution_note_window_action": "edit",
"msg_value": "add",
"message_pk": m.pk,
"resolution_note_pk": None,
"alert_group_pk": alert_group.pk,
}
},
organization,
),
},
},

View file

@ -4,6 +4,7 @@ import pytest
from apps.alerts.incident_appearance.renderers.slack_renderer import AlertGroupSlackRenderer
from apps.alerts.models import AlertGroup
from apps.slack.chatops_proxy_routing import make_value
@pytest.mark.django_db
@ -17,11 +18,15 @@ def test_slack_renderer_acknowledge_button(make_organization, make_alert_receive
button = elements[0]
assert button["text"]["text"] == "Acknowledge"
assert json.loads(button["value"]) == {
"organization_id": organization.pk,
"alert_group_pk": alert_group.pk,
"alert_group_ppk": alert_group.public_primary_key,
}
assert json.loads(button["value"]) == json.loads(
make_value(
{
"organization_id": organization.pk,
"alert_group_ppk": alert_group.public_primary_key,
},
organization,
)
)
@pytest.mark.django_db
@ -37,11 +42,15 @@ def test_slack_renderer_unacknowledge_button(
button = elements[0]
assert button["text"]["text"] == "Unacknowledge"
assert json.loads(button["value"]) == {
"organization_id": organization.pk,
"alert_group_pk": alert_group.pk,
"alert_group_ppk": alert_group.public_primary_key,
}
assert json.loads(button["value"]) == json.loads(
make_value(
{
"organization_id": organization.pk,
"alert_group_ppk": alert_group.public_primary_key,
},
organization,
)
)
@pytest.mark.django_db
@ -55,11 +64,15 @@ def test_slack_renderer_resolve_button(make_organization, make_alert_receive_cha
button = elements[1]
assert button["text"]["text"] == "Resolve"
assert json.loads(button["value"]) == {
"organization_id": organization.pk,
"alert_group_pk": alert_group.pk,
"alert_group_ppk": alert_group.public_primary_key,
}
assert json.loads(button["value"]) == json.loads(
make_value(
{
"organization_id": organization.pk,
"alert_group_ppk": alert_group.public_primary_key,
},
organization,
)
)
@pytest.mark.django_db
@ -73,11 +86,15 @@ def test_slack_renderer_unresolve_button(make_organization, make_alert_receive_c
button = elements[0]
assert button["text"]["text"] == "Unresolve"
assert json.loads(button["value"]) == {
"organization_id": organization.pk,
"alert_group_pk": alert_group.pk,
"alert_group_ppk": alert_group.public_primary_key,
}
assert json.loads(button["value"]) == json.loads(
make_value(
{
"organization_id": organization.pk,
"alert_group_ppk": alert_group.public_primary_key,
},
organization,
)
)
@pytest.mark.django_db
@ -109,12 +126,16 @@ def test_slack_renderer_stop_invite_button(
action = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[1]["actions"][0]
assert action["text"] == f"Stop inviting {user.username}"
assert json.loads(action["value"]) == {
"organization_id": organization.pk,
"alert_group_pk": alert_group.pk,
"alert_group_ppk": alert_group.public_primary_key,
"invitation_id": invitation.pk,
}
assert json.loads(action["value"]) == json.loads(
make_value(
{
"organization_id": organization.pk,
"alert_group_ppk": alert_group.public_primary_key,
"invitation_id": invitation.pk,
},
organization,
)
)
@pytest.mark.django_db
@ -131,12 +152,16 @@ def test_slack_renderer_silence_button(make_organization, make_alert_receive_cha
values = [json.loads(option["value"]) for option in button["options"]]
assert values == [
{
"organization_id": organization.pk,
"alert_group_pk": alert_group.pk,
"alert_group_ppk": alert_group.public_primary_key,
"delay": delay,
}
json.loads(
make_value(
{
"organization_id": organization.pk,
"alert_group_ppk": alert_group.public_primary_key,
"delay": delay,
},
organization,
)
)
for delay, _ in AlertGroup.SILENCE_DELAY_OPTIONS
]
@ -152,11 +177,15 @@ def test_slack_renderer_unsilence_button(make_organization, make_alert_receive_c
button = elements[2]
assert button["text"]["text"] == "Unsilence"
assert json.loads(button["value"]) == {
"organization_id": organization.pk,
"alert_group_pk": alert_group.pk,
"alert_group_ppk": alert_group.public_primary_key,
}
assert json.loads(button["value"]) == json.loads(
make_value(
{
"organization_id": organization.pk,
"alert_group_ppk": alert_group.public_primary_key,
},
organization,
)
)
@pytest.mark.django_db
@ -170,11 +199,15 @@ def test_slack_renderer_attach_button(make_organization, make_alert_receive_chan
button = elements[4]
assert button["text"]["text"] == "Attach to ..."
assert json.loads(button["value"]) == {
"organization_id": organization.pk,
"alert_group_pk": alert_group.pk,
"alert_group_ppk": alert_group.public_primary_key,
}
assert json.loads(button["value"]) == json.loads(
make_value(
{
"organization_id": organization.pk,
"alert_group_ppk": alert_group.public_primary_key,
},
organization,
)
)
@pytest.mark.django_db
@ -191,11 +224,15 @@ def test_slack_renderer_unattach_button(make_organization, make_alert_receive_ch
action = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[0]["actions"][0]
assert action["text"] == "Unattach"
assert json.loads(action["value"]) == {
"organization_id": organization.pk,
"alert_group_pk": alert_group.pk,
"alert_group_ppk": alert_group.public_primary_key,
}
assert json.loads(action["value"]) == json.loads(
make_value(
{
"organization_id": organization.pk,
"alert_group_ppk": alert_group.public_primary_key,
},
organization,
)
)
@pytest.mark.django_db
@ -211,11 +248,15 @@ def test_slack_renderer_format_alert_button(
button = elements[5]
assert button["text"]["text"] == ":mag: Format Alert"
assert json.loads(button["value"]) == {
"organization_id": organization.pk,
"alert_group_pk": alert_group.pk,
"alert_group_ppk": alert_group.public_primary_key,
}
assert json.loads(button["value"]) == json.loads(
make_value(
{
"organization_id": organization.pk,
"alert_group_ppk": alert_group.public_primary_key,
},
organization,
)
)
@pytest.mark.django_db
@ -231,9 +272,13 @@ def test_slack_renderer_resolution_notes_button(
button = elements[6]
assert button["text"]["text"] == "Add Resolution notes"
assert json.loads(button["value"]) == {
"organization_id": organization.pk,
"alert_group_pk": alert_group.pk,
"alert_group_ppk": alert_group.public_primary_key,
"resolution_note_window_action": "edit",
}
assert json.loads(button["value"]) == json.loads(
make_value(
{
"organization_id": organization.pk,
"alert_group_ppk": alert_group.public_primary_key,
"resolution_note_window_action": "edit",
},
organization,
)
)

View file

@ -9,17 +9,19 @@ from .views import (
)
urlpatterns = [
# Old urls for handling slack events and interactive messages. Currently used in OSS
path("event_api_endpoint/", SlackEventApiEndpointView.as_view()),
path("interactive_api_endpoint/", SlackEventApiEndpointView.as_view()),
# New urls used in cloud via chatops-proxy v3.
path("events/", SlackEventApiEndpointView.as_view()),
path("interactive/", SlackEventApiEndpointView.as_view()),
# Trailing / is missing here on purpose. QA the feature if you want to add it. No idea why doesn't it work with it.
path("reset_slack", ResetSlackView.as_view(), name="reset-slack"),
# Deprecated.
path("oauth/", OAuthSlackView.as_view()),
path("oauth/<str:subscription>/<str:utm>/", OAuthSlackView.as_view()),
path("install_redirect/", InstallLinkRedirectView.as_view()),
path("install_redirect/<str:subscription>/<str:utm>/", InstallLinkRedirectView.as_view()),
path("signup_redirect/", SignupRedirectView.as_view()),
path("signup_redirect/<str:subscription>/<str:utm>/", SignupRedirectView.as_view()),
# Trailing / is missing here on purpose. QA the feature if you want to add it. No idea why doesn't it work with it.
path("reset_slack", ResetSlackView.as_view(), name="reset-slack"),
# urls for chatops-proxy v3. Currently, they are experimental.
path("events/", SlackEventApiEndpointView.as_view()),
path("interactive/", SlackEventApiEndpointView.as_view()),
]

View file

@ -35,13 +35,11 @@ from apps.slack.scenarios.shift_swap_requests import STEPS_ROUTING as SHIFT_SWAP
from apps.slack.scenarios.slack_channel import STEPS_ROUTING as CHANNEL_ROUTING
from apps.slack.scenarios.slack_channel_integration import STEPS_ROUTING as SLACK_CHANNEL_INTEGRATION_ROUTING
from apps.slack.scenarios.slack_usergroup import STEPS_ROUTING as SLACK_USERGROUP_UPDATE_ROUTING
from apps.slack.tasks import clean_slack_integration_leftovers, unpopulate_slack_user_identities
from apps.slack.types import EventPayload, EventType, MessageEventSubtype, PayloadType, ScenarioRoute
from apps.user_management.models import Organization
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
from common.oncall_gateway import unlink_slack_team_wrapper
from .errors import SlackAPITokenError
from .installation import SlackInstallationExc, uninstall_slack_integration
from .models import SlackMessage, SlackTeamIdentity, SlackUserIdentity
SCENARIOS_ROUTES: ScenarioRoute.RoutingSteps = []
@ -565,25 +563,14 @@ class ResetSlackView(APIView):
}
def post(self, request):
# TODO: this check should be removed once Unified Slack App is release
if settings.SLACK_INTEGRATION_MAINTENANCE_ENABLED:
response = Response(
return Response(
"Grafana OnCall is temporary unable to connect your slack account or install OnCall to your slack workspace",
status=400,
)
else:
organization = request.auth.organization
slack_team_identity = organization.slack_team_identity
if slack_team_identity is not None:
clean_slack_integration_leftovers.apply_async((organization.pk,))
if settings.FEATURE_MULTIREGION_ENABLED:
unlink_slack_team_wrapper(str(organization.uuid), slack_team_identity.slack_id)
write_chatops_insight_log(
author=request.user,
event_name=ChatOpsEvent.WORKSPACE_DISCONNECTED,
chatops_type=ChatOpsTypePlug.SLACK.value,
)
unpopulate_slack_user_identities(organization.pk, True)
response = Response(status=200)
else:
response = Response(status=400)
return response
try:
uninstall_slack_integration(request.user.organization, request.user)
except SlackInstallationExc as e:
return Response({"error": e.error_message}, status=400)
return Response(status=200)

View file

@ -91,7 +91,10 @@ class SlackOAuth2V2(SlackOAuth2):
@handle_http_errors
def auth_complete(self, *args, **kwargs):
"""Completes login process, must return user instance"""
"""
Override original method to include auth token in redirect uri and adjust response shape to slack Oauth2.0 V2.
Access token is in the ["authed_user"]["access_token"] field, not in the root of the response.
"""
self.process_error(self.data)
state = self.validate_state()
# add auth token to redirect uri, because it must be the same in all slack auth requests
@ -113,6 +116,7 @@ class SlackOAuth2V2(SlackOAuth2):
method=self.ACCESS_TOKEN_METHOD,
)
self.process_error(response)
# Take access token from the authed_user field, not from the root
access_token = response["authed_user"]["access_token"]
kwargs.update(response=response)
return self.do_auth(access_token, *args, **kwargs)
@ -187,8 +191,13 @@ class LoginSlackOAuth2V2(SlackOAuth2V2):
return {"user_scope": USER_SCOPE}
# it's named slack-install-free because it was used to install free version of Slack App.
# There is no free/paid version of Slack App anymore, so it's just a name.
SLACK_INSTALLATION_BACKEND = "slack-install-free"
class InstallSlackOAuth2V2(SlackOAuth2V2):
name = "slack-install-free"
name = SLACK_INSTALLATION_BACKEND
def get_scope(self):
return {"user_scope": USER_SCOPE, "scope": BOT_SCOPE}

View file

@ -6,11 +6,12 @@ from django.contrib.auth import REDIRECT_FIELD_NAME
from django.http import HttpResponse
from rest_framework import status
from apps.slack.tasks import populate_slack_channels_for_team, populate_slack_usergroups_for_team
from apps.chatops_proxy.utils import can_link_slack_team, link_slack_team
from apps.slack.installation import SlackInstallationExc, install_slack_integration
from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND
from apps.social_auth.exceptions import InstallMultiRegionSlackException
from common.constants.slack_auth import SLACK_AUTH_SLACK_USER_ALREADY_CONNECTED_ERROR, SLACK_AUTH_WRONG_WORKSPACE_ERROR
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
from common.oncall_gateway import can_link_slack_team_wrapper, link_slack_team_wrapper
logger = logging.getLogger(__name__)
@ -72,34 +73,21 @@ def connect_user_to_slack(response, backend, strategy, user, organization, *args
def populate_slack_identities(response, backend, user, organization, **kwargs):
from apps.slack.models import SlackTeamIdentity
# Continue pipeline step only if it was installation
if backend.name != "slack-install-free":
if backend.name != SLACK_INSTALLATION_BACKEND:
return
if organization.slack_team_identity is not None:
# means that organization already has Slack integration
return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
slack_team_id = response["team"]["id"]
if settings.LICENSE == settings.CLOUD_LICENSE_NAME:
can_link = can_link_slack_team_wrapper(str(organization.uuid), slack_team_id, settings.ONCALL_BACKEND_REGION)
if settings.FEATURE_MULTIREGION_ENABLED and not can_link:
if settings.FEATURE_MULTIREGION_ENABLED and not settings.UNIFIED_SLACK_APP_ENABLED:
can_link = can_link_slack_team(str(organization.uuid), slack_team_id, settings.ONCALL_BACKEND_REGION)
if not can_link:
raise InstallMultiRegionSlackException
slack_team_identity, is_slack_team_identity_created = SlackTeamIdentity.objects.get_or_create(
slack_id=slack_team_id,
)
# update slack oauth fields by data from response
slack_team_identity.update_oauth_fields(user, organization, response)
if settings.FEATURE_MULTIREGION_ENABLED:
link_slack_team_wrapper(str(organization.uuid), slack_team_id)
populate_slack_channels_for_team.apply_async((slack_team_identity.pk,))
user.slack_user_identity.update_profile_info()
# todo slack: do we need update info for all existing slack users in slack team?
# populate_slack_user_identities.apply_async((organization.pk,))
populate_slack_usergroups_for_team.apply_async((slack_team_identity.pk,), countdown=10)
try:
install_slack_integration(organization, user, response)
except SlackInstallationExc:
return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
if settings.FEATURE_MULTIREGION_ENABLED and not settings.UNIFIED_SLACK_APP_ENABLED:
link_slack_team(str(organization.uuid), slack_team_id)
def delete_slack_auth_token(strategy, *args, **kwargs):

View file

@ -39,14 +39,13 @@ class TelegramClient:
return False
def register_webhook(self, webhook_url: Optional[str] = None) -> None:
# Hack to test chatops-proxy v3, remove once v3 is release.
if settings.CHATOPS_V3:
if settings.IS_OPEN_SOURCE:
webhook_url = webhook_url or create_engine_url(
"api/v3/webhook/telegram/", override_base=live_settings.TELEGRAM_WEBHOOK_HOST
"/telegram/", override_base=live_settings.TELEGRAM_WEBHOOK_HOST
)
else:
webhook_url = webhook_url or create_engine_url(
"/telegram/", override_base=live_settings.TELEGRAM_WEBHOOK_HOST
"api/v3/webhook/telegram/", override_base=live_settings.TELEGRAM_WEBHOOK_HOST
)
# avoid unnecessary set_webhook calls to make sure Telegram rate limits are not exceeded
webhook_info = self.api_client.get_webhook_info()

View file

@ -11,14 +11,10 @@ from django.utils import timezone
from mirage import fields as mirage_fields
from apps.alerts.models import MaintainableObject
from apps.chatops_proxy.utils import register_oncall_tenant, unlink_slack_team, unregister_oncall_tenant
from apps.user_management.subscription_strategy import FreePublicBetaSubscriptionStrategy
from apps.user_management.types import AlertGroupTableColumn
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
from common.oncall_gateway import (
register_oncall_tenant_wrapper,
unlink_slack_team_wrapper,
unregister_oncall_tenant_wrapper,
)
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
if typing.TYPE_CHECKING:
@ -65,7 +61,7 @@ class OrganizationQuerySet(models.QuerySet):
def create(self, **kwargs):
instance = super().create(**kwargs)
if settings.FEATURE_MULTIREGION_ENABLED:
register_oncall_tenant_wrapper(str(instance.uuid), settings.ONCALL_BACKEND_REGION)
register_oncall_tenant(str(instance.uuid), settings.ONCALL_BACKEND_REGION, instance.stack_id)
return instance
def delete(self):
@ -108,9 +104,9 @@ class Organization(MaintainableObject):
def delete(self):
if settings.FEATURE_MULTIREGION_ENABLED:
unregister_oncall_tenant_wrapper(str(self.uuid), settings.ONCALL_BACKEND_REGION)
if self.slack_team_identity:
unlink_slack_team_wrapper(str(self.uuid), self.slack_team_identity.slack_id)
unregister_oncall_tenant(str(self.uuid), settings.ONCALL_BACKEND_REGION)
if self.slack_team_identity and not settings.UNIFIED_SLACK_APP_ENABLED:
unlink_slack_team(str(self.uuid), self.slack_team_identity.slack_id)
self.deleted_at = timezone.now()
self.save(update_fields=["deleted_at"])

View file

@ -3,3 +3,19 @@ REDIRECT_AFTER_SLACK_INSTALL = "redirect_after_slack_install"
SLACK_AUTH_WRONG_WORKSPACE_ERROR = "wrong_workspace"
SLACK_AUTH_SLACK_USER_ALREADY_CONNECTED_ERROR = "user_already_connected"
SLACK_AUTH_FAILED = "auth_failed"
# Example of a slack oauth response to be used in tests.
# It contains NO actual tokens, got it from slack docs.
# https://api.slack.com/authentication/oauth-v2
SLACK_OAUTH_ACCESS_RESPONSE = {
"ok": True,
"access_token": "xoxb-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy",
"token_type": "bot",
"scope": "commands,incoming-webhook",
"bot_user_id": "U0KRQLJ9H",
"app_id": "A0KRD7HC3",
"team": {"name": "Slack Softball Team", "id": "T9TK3CUKW"},
"enterprise": {"name": "slack-sports", "id": "E12345678"},
"authed_user": {"id": "U1234", "scope": "chat:write", "access_token": "xoxp-1234", "token_type": "user"},
}

View file

@ -1,11 +0,0 @@
"""
This package is for interaction with OnCall-Gateway, service to provide multiregional chatops.
"""
from .utils import ( # noqa: F401
can_link_slack_team_wrapper,
link_slack_team_wrapper,
register_oncall_tenant_wrapper,
unlink_slack_team_wrapper,
unregister_oncall_tenant_wrapper,
)

View file

@ -1,138 +0,0 @@
import json
from dataclasses import dataclass
from urllib.parse import urljoin
import requests
from django.conf import settings
@dataclass
class OnCallConnector:
"""
OnCallConnector represents connection between oncall org and oncall-gateway
"""
oncall_org_id: str
backend: str
@dataclass
class SlackConnector:
"""
SlackConnector represents connection between slack team with installed oncall app and oncall-gateway
"""
oncall_org_id: str
slack_team_id: str
backend: str
DEFAULT_TIMEOUT = 5
class OnCallGatewayAPIClient:
"""
It's a legacy api client, which should be removed after chatops proxy v3 release.
"""
def __init__(self, url: str, token: str):
self.base_url = url
self.api_base_url = urljoin(self.base_url, "api/v1/")
self.api_token = token
# OnCall Connector
@property
def _oncall_connectors_url(self) -> str:
return urljoin(self.api_base_url, "oncall_org_connectors")
def post_oncall_connector(
self, oncall_org_id: str, backend: str
) -> tuple[OnCallConnector, requests.models.Response]:
d = {"oncall_org_id": oncall_org_id, "backend": backend}
response = self._post(url=self._oncall_connectors_url, json=d)
response_data = response.json()
return OnCallConnector(oncall_org_id=response_data["oncall_org_id"], backend=response_data["backend"]), response
def delete_oncall_connector(self, oncall_org_id: str) -> requests.models.Response:
url = urljoin(f"{self._oncall_connectors_url}/", oncall_org_id)
response = self._delete(url=url)
return response
# Slack Connector
@property
def _slack_connectors_url(self) -> str:
return urljoin(self.api_base_url, "slack_team_connectors")
def post_slack_connector(
self, oncall_org_id: str, slack_id: str, backend: str
) -> tuple[SlackConnector, requests.models.Response]:
d = {"oncall_org_id": oncall_org_id, "slack_team_id": slack_id, "backend": backend}
response = self._post(url=self._slack_connectors_url, json=d)
response_data = response.json()
return (
SlackConnector(
response_data["oncall_org_id"],
response_data["slack_team_id"],
response_data["backend"],
),
response,
)
def delete_slack_connector(self, oncall_org_id: str) -> requests.models.Response:
url = urljoin(f"{self._slack_connectors_url}/", oncall_org_id)
response = self._delete(url=url)
return response
def check_slack_installation_possible(self, oncall_org_id, backend, slack_id: str) -> requests.models.Response:
url = urljoin(f"{self._slack_connectors_url}/", "check_installation_possible")
url += f"?slack_team_id={slack_id}&oncall_org_id={oncall_org_id}&backend={backend}"
return self._get(url=url)
def _get(self, url, params=None, **kwargs) -> requests.models.Response:
kwargs["params"] = params
response = self._call_api(method=requests.get, url=url, **kwargs)
return response
def _post(self, url, data=None, json=None, **kwargs) -> requests.models.Response:
kwargs["data"] = data
kwargs["json"] = json
response = self._call_api(method=requests.post, url=url, **kwargs)
return response
def _delete(self, url, **kwargs) -> requests.models.Response:
response = self._call_api(method=requests.delete, url=url, **kwargs)
return response
def _call_api(self, method, url, **kwargs) -> requests.models.Response:
kwargs["headers"] = self._headers | kwargs.get("headers", {})
response = method(url, **kwargs)
self._check_response(response)
return response
@property
def _headers(self) -> dict:
return {
"User-Agent": settings.GRAFANA_COM_USER_AGENT,
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json",
}
@classmethod
def _check_response(cls, response: requests.models.Response):
if response.status_code not in [200, 201, 202, 204]:
err_msg = cls._get_error_msg_from_response(response)
if 400 <= response.status_code < 500:
err_msg = "%s Client Error: %s for url: %s" % (response.status_code, err_msg, response.url)
elif 500 <= response.status_code < 600:
err_msg = "%s Server Error: %s for url: %s" % (response.status_code, err_msg, response.url)
raise requests.exceptions.HTTPError(err_msg, response=response)
@classmethod
def _get_error_msg_from_response(cls, response: requests.models.Response) -> str:
error_msg = ""
try:
error_msg = response.json()["message"]
except (json.JSONDecodeError, KeyError):
error_msg = response.text if response.text else response.reason
return error_msg

View file

@ -1,137 +1,16 @@
import requests
from celery.utils.log import get_task_logger
from django.conf import settings
from apps.chatops_proxy import tasks as new_tasks
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
from .client import ChatopsProxyAPIClient, ChatopsProxyAPIException
from .legacy_client import OnCallGatewayAPIClient
task_logger = get_task_logger(__name__)
@shared_dedicated_queue_retry_task(
autoretry_for=(Exception,),
retry_backoff=True,
max_retries=100,
)
def create_oncall_connector_async(oncall_org_id, backend):
client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
client.post_oncall_connector(oncall_org_id, backend)
except requests.exceptions.HTTPError as http_exc:
if http_exc.response.status_code == 409:
# 409 Indicates that it's impossible to create such connector.
# More likely because it already exists.
task_logger.error(
f"Failed to create OnCallConnector oncall_org_id={oncall_org_id} backend={backend} exc={http_exc}"
)
else:
raise http_exc
except Exception as e:
task_logger.error(f"Failed to create OnCallConnector oncall_org_id={oncall_org_id} backend={backend} exc={e}")
raise e
@shared_dedicated_queue_retry_task(
autoretry_for=(Exception,),
retry_backoff=True,
max_retries=100,
)
def delete_oncall_connector_async(oncall_org_id):
client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
client.delete_oncall_connector(oncall_org_id)
except requests.exceptions.HTTPError as http_exc:
if http_exc.response.status_code == 404:
# 404 indicates that connector was deleted already
return
else:
task_logger.error(f"Failed to delete OnCallConnector oncall_org_id={oncall_org_id} exc={http_exc}")
raise http_exc
except Exception as e:
task_logger.error(f"Failed to delete OnCallConnector oncall_org_id={oncall_org_id} exc={e}")
raise e
@shared_dedicated_queue_retry_task(
autoretry_for=(Exception,),
retry_backoff=True,
max_retries=100,
)
def create_slack_connector_async_v2(**kwargs):
oncall_org_id = kwargs.get("oncall_org_id")
slack_team_id = kwargs.get("slack_team_id")
backend = kwargs.get("backend")
client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
client.post_slack_connector(oncall_org_id, slack_team_id, backend)
except requests.exceptions.HTTPError as http_exc:
if http_exc.response.status_code == 409:
# 409 Indicates that it's impossible to create such connector.
# More likely because it already exists.
task_logger.error(
f"Failed to create SlackConnector oncall_org_id={oncall_org_id} backend={backend} exc={http_exc}"
)
else:
raise http_exc
except Exception as e:
task_logger.error(f"Failed to create SlackConnector slack_id={oncall_org_id} backend={backend} exc={e}")
raise e
@shared_dedicated_queue_retry_task(
autoretry_for=(Exception,),
retry_backoff=True,
max_retries=100,
)
def delete_slack_connector_async_v2(**kwargs):
oncall_org_id = kwargs.get("oncall_org_id")
client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
client.delete_slack_connector(oncall_org_id)
except requests.exceptions.HTTPError as http_exc:
if http_exc.response.status_code == 404:
# 404 indicates that connector was deleted already
return
else:
raise http_exc
except Exception as e:
task_logger.error(f"Failed to delete SlackConnectorV2 oncall_org_id={oncall_org_id} exc={e}")
raise e
# New tasks to use once chatops v3 is landed
@shared_dedicated_queue_retry_task(
autoretry_for=(Exception,),
retry_backoff=True,
max_retries=100,
)
def register_oncall_tenant_async(**kwargs):
service_tenant_id = kwargs.get("service_tenant_id")
cluster_slug = kwargs.get("cluster_slug")
service_type = kwargs.get("service_type")
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
client.register_tenant(service_tenant_id, cluster_slug, service_type)
except ChatopsProxyAPIException as api_exc:
task_logger.error(
f'msg="Failed to register OnCall tenant: {api_exc.msg}" service_tenant_id={service_tenant_id} cluster_slug={cluster_slug}'
)
if api_exc.status == 409:
# 409 Indicates that it's impossible to register tenant, because tenant already registered.
# Not retrying in this case, because manual conflict-resolution needed.
return
else:
# Otherwise keep retrying task
raise api_exc
except Exception as e:
# Keep retrying task for any other exceptions too
task_logger.error(
f"Failed to register OnCall tenant: {e} service_tenant_id={service_tenant_id} cluster_slug={cluster_slug}"
)
raise e
new_tasks.register_oncall_tenant_async.apply_async(
kwargs=kwargs,
)
@shared_dedicated_queue_retry_task(
@ -140,23 +19,9 @@ def register_oncall_tenant_async(**kwargs):
max_retries=100,
)
def unregister_oncall_tenant_async(**kwargs):
service_tenant_id = kwargs.get("service_tenant_id")
cluster_slug = kwargs.get("cluster_slug")
service_type = kwargs.get("service_type")
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
client.unregister_tenant(service_tenant_id, cluster_slug, service_type)
except ChatopsProxyAPIException as api_exc:
if api_exc.status == 400:
# 400 Indicates that tenant is already deleted
return
else:
# Otherwise keep retrying task
raise api_exc
except Exception as e:
task_logger.error(f"Failed to delete OnCallTenant: {e} service_tenant_id={service_tenant_id}")
raise e
new_tasks.unregister_oncall_tenant_async.apply_async(
kwargs=kwargs,
)
@shared_dedicated_queue_retry_task(
@ -165,27 +30,9 @@ def unregister_oncall_tenant_async(**kwargs):
max_retries=100,
)
def link_slack_team_async(**kwargs):
service_tenant_id = kwargs.get("service_tenant_id")
service_type = kwargs.get("service_type")
slack_team_id = kwargs.get("slack_team_id")
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
client.link_slack_team(service_tenant_id, slack_team_id, service_type)
except ChatopsProxyAPIException as api_exc:
task_logger.error(
f'msg="Failed to link slack team: {api_exc.msg}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}'
)
if api_exc.status == 409:
# Impossible to register tenant, slack workspace already connected to another cluster.
# Not retrying in this case, because manual conflict-resolution needed.
return
else:
raise api_exc
except Exception as e:
task_logger.error(
f'msg="Failed to link slack team: {e}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}'
)
raise e
new_tasks.link_slack_team_async.apply_async(
kwargs=kwargs,
)
@shared_dedicated_queue_retry_task(
@ -194,22 +41,6 @@ def link_slack_team_async(**kwargs):
max_retries=100,
)
def unlink_slack_team_async(**kwargs):
service_tenant_id = kwargs.get("service_tenant_id")
service_type = kwargs.get("service_type")
slack_team_id = kwargs.get("slack_team_id")
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
client.unlink_slack_team(service_tenant_id, slack_team_id, service_type)
except ChatopsProxyAPIException as api_exc:
if api_exc.status == 400:
# 400 Indicates that tenant is already deleted
return
else:
# Otherwise keep retrying task
raise api_exc
except Exception as e:
task_logger.error(
f'msg="Failed to unlink slack_team: {e}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}'
)
raise e
new_tasks.unlink_slack_team_async.apply_async(
kwargs=kwargs,
)

View file

@ -1,196 +0,0 @@
"""
Set of utils to handle oncall and chatops-proxy interaction.
TODO: Once chatops v3 will be released, remove legacy and wrapper functions
"""
import logging
import requests
from django.conf import settings
from .client import SERVICE_TYPE_ONCALL, ChatopsProxyAPIClient
from .legacy_client import OnCallGatewayAPIClient
from .tasks import (
create_oncall_connector_async,
create_slack_connector_async_v2,
delete_oncall_connector_async,
delete_slack_connector_async_v2,
link_slack_team_async,
register_oncall_tenant_async,
unlink_slack_team_async,
unregister_oncall_tenant_async,
)
logger = logging.getLogger(__name__)
# Legacy to work with chatops-proxy v1.
def create_oncall_connector(oncall_org_id: str, backend: str):
client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
client.post_oncall_connector(oncall_org_id, backend)
except Exception as e:
logger.error(f"create_oncall_connector: failed " f"oncall_org_id={oncall_org_id} backend={backend} exc={e}")
create_oncall_connector_async.apply_async((oncall_org_id, backend), countdown=2)
def delete_oncall_connector(oncall_org_id: str):
delete_oncall_connector_async.delay(oncall_org_id)
def check_slack_installation_possible(oncall_org_id: str, slack_id: str, backend: str) -> bool:
client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
response = client.check_slack_installation_possible(
oncall_org_id=oncall_org_id, slack_id=slack_id, backend=backend
)
return response.status_code == 200
except requests.exceptions.HTTPError as http_exc:
logger.error(
f"check_slack_installation_backend: slack installation impossible "
f"oncall_org_id={oncall_org_id} slack_id={slack_id} backend={backend} exc={http_exc}"
)
return False
def create_slack_connector(oncall_org_id: str, slack_id: str, backend: str):
client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
client.post_slack_connector(oncall_org_id, slack_id, backend)
except Exception as e:
logger.error(
f"create_slack_connector: failed "
f"oncall_org_id={oncall_org_id} slack_id={slack_id} backend={backend} exc={e}"
)
create_slack_connector_async_v2.apply_async(
kwargs={"oncall_org_id": oncall_org_id, "slack_id": slack_id, "backend": backend}, countdown=2
)
def delete_slack_connector(oncall_org_id: str):
delete_slack_connector_async_v2.delay(oncall_org_id=oncall_org_id)
# utils to work with v3 version
def register_oncall_tenant(service_tenant_id: str, cluster_slug: str):
"""
register_oncall_tenant tries to register oncall tenant synchronously and fall back to task in case of any exceptions
to make sure that tenant is registered.
First attempt is synchronous to register tenant ASAP to not miss any chatops requests.
"""
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
client.register_tenant(service_tenant_id, cluster_slug, SERVICE_TYPE_ONCALL)
except Exception as e:
logger.error(
f"create_oncall_connector: failed " f"oncall_org_id={service_tenant_id} backend={cluster_slug} exc={e}"
)
register_oncall_tenant_async.apply_async(
kwargs={
"service_tenant_id": service_tenant_id,
"cluster_slug": cluster_slug,
"service_type": SERVICE_TYPE_ONCALL,
},
countdown=2,
)
def unregister_oncall_tenant(service_tenant_id: str, cluster_slug: str):
"""
unregister_oncall_tenant unregisters tenant asynchronously.
"""
unregister_oncall_tenant_async.apply_async(
kwargs={
"service_tenant_id": service_tenant_id,
"cluster_slug": cluster_slug,
"service_type": SERVICE_TYPE_ONCALL,
},
countdown=2,
)
def can_link_slack_team(
service_tenant_id: str,
slack_team_id: str,
cluster_slug: str,
) -> bool:
"""
can_link_slack_team checks if it's possible to link slack workspace to oncall tenant located in cluster.
All oncall tenants linked to same slack team should have same cluster.
"""
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
response = client.can_slack_link(service_tenant_id, cluster_slug, slack_team_id, SERVICE_TYPE_ONCALL)
return response.status_code == 200
except Exception as e:
logger.error(
f"can_link_slack_team: slack installation impossible: {e} "
f"service_tenant_id={service_tenant_id} slack_team_id={slack_team_id} cluster_slug={cluster_slug}"
)
return False
def link_slack_team(service_tenant_id: str, slack_team_id: str):
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
try:
client.link_slack_team(service_tenant_id, slack_team_id, SERVICE_TYPE_ONCALL)
except Exception as e:
logger.error(
f'msg="Failed to link slack team: {e}"'
f"service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}"
)
link_slack_team_async.apply_async(
kwargs={
"service_tenant_id": service_tenant_id,
"slack_team_id": slack_team_id,
"service_type": SERVICE_TYPE_ONCALL,
},
countdown=2,
)
def unlink_slack_team(service_tenant_id: str, slack_team_id: str):
unlink_slack_team_async.apply_async(
kwargs={
"service_tenant_id": service_tenant_id,
"slack_team_id": slack_team_id,
"service_type": SERVICE_TYPE_ONCALL,
}
)
# Wrappers to choose whether legacy or v3 function should be call, depending on CHATOPS_V3 env var.
def register_oncall_tenant_wrapper(service_tenant_id: str, cluster_slug: str):
if settings.CHATOPS_V3:
register_oncall_tenant(service_tenant_id, cluster_slug)
else:
create_oncall_connector(service_tenant_id, cluster_slug)
def unregister_oncall_tenant_wrapper(service_tenant_id: str, cluster_slug: str):
if settings.CHATOPS_V3:
unregister_oncall_tenant(service_tenant_id, cluster_slug)
else:
delete_oncall_connector(service_tenant_id)
def can_link_slack_team_wrapper(service_tenant_id: str, slack_team_id, cluster_slug: str) -> bool:
if settings.CHATOPS_V3:
return can_link_slack_team(service_tenant_id, slack_team_id, cluster_slug)
else:
return check_slack_installation_possible(service_tenant_id, slack_team_id, cluster_slug)
def link_slack_team_wrapper(service_tenant_id: str, slack_team_id: str):
if settings.CHATOPS_V3:
link_slack_team(service_tenant_id, slack_team_id)
else:
create_slack_connector(service_tenant_id, slack_team_id, settings.ONCALL_BACKEND_REGION)
def unlink_slack_team_wrapper(service_tenant_id: str, slack_team_id: str):
if settings.CHATOPS_V3:
unlink_slack_team(service_tenant_id, slack_team_id)
else:
delete_slack_connector(service_tenant_id)

View file

@ -58,9 +58,11 @@ if settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED:
if settings.FEATURE_SLACK_INTEGRATION_ENABLED:
urlpatterns += [
path("api/internal/v1/slack/", include("apps.slack.urls")),
path("api/v3/webhook/slack/", include("apps.slack.urls")),
path("slack/", include("apps.slack.urls")),
]
if settings.IS_OPEN_SOURCE:
urlpatterns += [
path("api/internal/v1/", include("apps.oss_installation.urls", namespace="oss_installation")),
@ -88,3 +90,8 @@ if settings.DRF_SPECTACULAR_ENABLED:
path("internal/schema/", SpectacularYAMLAPIView.as_view(api_version="internal/v1"), name="schema"),
path("internal/schema/swagger-ui/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
]
if settings.UNIFIED_SLACK_APP_ENABLED:
urlpatterns += [
path("api/chatops/", include("apps.chatops_proxy.urls")),
]

View file

@ -98,7 +98,9 @@ WEBHOOK_RESPONSE_LIMIT = 50000
ONCALL_GATEWAY_URL = os.environ.get("ONCALL_GATEWAY_URL", "")
ONCALL_GATEWAY_API_TOKEN = os.environ.get("ONCALL_GATEWAY_API_TOKEN", "")
ONCALL_BACKEND_REGION = os.environ.get("ONCALL_BACKEND_REGION")
CHATOPS_V3 = getenv_boolean("CHATOPS_V3", False)
UNIFIED_SLACK_APP_ENABLED = getenv_boolean("UNIFIED_SLACK_APP_ENABLED", default=False)
# secret to verify the incoming requests from the chatops-proxy
CHATOPS_SIGNING_SECRET = os.environ.get("CHATOPS_SIGNING_SECRET", None)
# Prometheus exporter metrics endpoint auth
PROMETHEUS_EXPORTER_SECRET = os.environ.get("PROMETHEUS_EXPORTER_SECRET")
@ -281,6 +283,7 @@ INSTALLED_APPS = [
"apps.phone_notifications",
"drf_spectacular",
"apps.google",
"apps.chatops_proxy",
]
REST_FRAMEWORK = {
@ -779,7 +782,7 @@ GRAFANA_CLOUD_AUTH_API_URL = os.environ.get("GRAFANA_CLOUD_AUTH_API_URL", None)
GRAFANA_CLOUD_AUTH_API_SYSTEM_TOKEN = os.environ.get("GRAFANA_CLOUD_AUTH_API_SYSTEM_TOKEN", None)
SELF_HOSTED_SETTINGS = {
"STACK_ID": 5,
"STACK_ID": getenv_integer("SELF_HOSTED_STACK_ID", 5),
"STACK_SLUG": os.environ.get("SELF_HOSTED_STACK_SLUG", "self_hosted_stack"),
"ORG_ID": 100,
"ORG_SLUG": os.environ.get("SELF_HOSTED_ORG_SLUG", "self_hosted_org"),

View file

@ -87,6 +87,10 @@ CELERY_TASK_ROUTES = {
"common.oncall_gateway.tasks.unlink_slack_team_async": {"queue": "default"},
"common.oncall_gateway.tasks.register_oncall_tenant_async": {"queue": "default"},
"common.oncall_gateway.tasks.unregister_oncall_tenant_async": {"queue": "default"},
"apps.chatops_proxy.tasks.link_slack_team_async": {"queue": "default"},
"apps.chatops_proxy.tasks.unlink_slack_team_async": {"queue": "default"},
"apps.chatops_proxy.tasks.register_oncall_tenant_async": {"queue": "default"},
"apps.chatops_proxy.tasks.unregister_oncall_tenant_async": {"queue": "default"},
# CRITICAL
"apps.alerts.tasks.acknowledge_reminder.acknowledge_reminder_task": {"queue": "critical"},
"apps.alerts.tasks.acknowledge_reminder.unacknowledge_timeout_task": {"queue": "critical"},

View file

@ -2,8 +2,10 @@ import { action, observable, makeObservable, runInAction } from 'mobx';
import { BaseStore } from 'models/base_store';
import { SlackChannel } from 'models/slack_channel/slack_channel.types';
import { makeRequest } from 'network/network';
import { makeRequest, makeRequestRaw } from 'network/network';
import { RootStore } from 'state/rootStore';
import { GENERIC_ERROR } from 'utils/consts';
import { openErrorNotification } from 'utils/utils';
import { SlackSettings } from './slack.types';
@ -81,8 +83,19 @@ export class SlackStore extends BaseStore {
}
async installSlackIntegration() {
const url_for_redirect = await makeRequest('/login/slack-install-free/', {});
window.location = url_for_redirect;
try {
const response = await makeRequestRaw('/login/slack-install-free/', {});
if (response.status === 201) {
this.rootStore.organizationStore.loadCurrentOrganization();
} else if (response.status === 200) {
window.location = response.data;
}
} catch (ex) {
if (ex.response?.status === 500) {
openErrorNotification(GENERIC_ERROR);
}
}
}
async removeSlackIntegration() {

View file

@ -39,7 +39,7 @@ interface RequestConfig {
export const isNetworkError = axios.isAxiosError;
export const makeRequest = async <RT = any>(path: string, config: RequestConfig) => {
export const makeRequestRaw = async (path: string, config: RequestConfig) => {
const { method = 'GET', params, data, validateStatus, headers } = config;
const url = `${API_PROXY_PREFIX}${API_PATH_PREFIX}${path}`;
@ -56,7 +56,7 @@ export const makeRequest = async <RT = any>(path: string, config: RequestConfig)
});
FaroHelper.pushAxiosNetworkResponseEvent({ name: 'Request succeeded', res: response });
return response.data as RT;
return response;
} catch (ex) {
const error = ex as AxiosError;
FaroHelper.pushAxiosNetworkResponseEvent({ name: 'Request failed', res: error.response });
@ -64,3 +64,12 @@ export const makeRequest = async <RT = any>(path: string, config: RequestConfig)
throw ex;
}
};
export const makeRequest = async <RT = any>(path: string, config: RequestConfig) => {
try {
const result = await makeRequestRaw(path, config);
return result.data as RT;
} catch (ex) {
throw ex;
}
};