From 829ed8230b8bade00f4d1648ac4276e5e24a4b0f Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Mon, 6 Jun 2022 16:02:09 +0400 Subject: [PATCH] Make CloudConnection instance wide --- engine/apps/api/views/live_setting.py | 10 +++++ engine/apps/base/models/live_setting.py | 1 + engine/apps/base/utils.py | 20 +++------ engine/apps/oss_installation/constants.py | 2 - .../apps/oss_installation/models/__init__.py | 4 +- ...zation_connector.py => cloud_connector.py} | 41 +++++++++++-------- .../{heartbeat.py => cloud_heartbeat.py} | 0 .../models/cloud_user_identity.py | 12 +++--- .../serializers/cloud_user.py | 6 +-- engine/apps/oss_installation/urls.py | 5 +-- engine/apps/oss_installation/utils.py | 36 ++++++++++++++-- .../apps/oss_installation/views/__init__.py | 4 +- .../views/cloud_connection.py | 35 ++++++++++++++++ .../views/cloud_heartbeat_status.py | 15 ------- .../oss_installation/views/cloud_status.py | 19 --------- .../oss_installation/views/cloud_users.py | 12 +++--- engine/engine/urls.py | 2 +- engine/settings/base.py | 2 +- 18 files changed, 128 insertions(+), 98 deletions(-) rename engine/apps/oss_installation/models/{cloud_organization_connector.py => cloud_connector.py} (83%) rename engine/apps/oss_installation/models/{heartbeat.py => cloud_heartbeat.py} (100%) create mode 100644 engine/apps/oss_installation/views/cloud_connection.py delete mode 100644 engine/apps/oss_installation/views/cloud_heartbeat_status.py delete mode 100644 engine/apps/oss_installation/views/cloud_status.py diff --git a/engine/apps/api/views/live_setting.py b/engine/apps/api/views/live_setting.py index 2ed6d723..4c9b7beb 100644 --- a/engine/apps/api/views/live_setting.py +++ b/engine/apps/api/views/live_setting.py @@ -12,6 +12,7 @@ from apps.api.serializers.live_setting import LiveSettingSerializer from apps.auth_token.auth import PluginAuthentication from apps.base.models import LiveSetting from apps.base.utils import live_settings +from apps.oss_installation.models import CloudConnector from apps.slack.tasks import unpopulate_slack_user_identities from apps.telegram.client import TelegramClient from apps.telegram.tasks import register_telegram_webhook @@ -66,6 +67,15 @@ class LiveSettingViewSet(PublicPrimaryKeyMixin, viewsets.ModelViewSet): if sti is not None: unpopulate_slack_user_identities.apply_async((sti.pk, True), countdown=0) + if instance.name == "GRAFANA_CLOUD_ONCALL_TOKEN": + try: + old_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN + except ImproperlyConfigured: + old_token = None + + if old_token != new_value: + CloudConnector.remove_sync() + def _reset_telegram_integration(self, new_token): # tell Telegram to cancel sending events from old bot with suppress(ImproperlyConfigured, error.InvalidToken, error.Unauthorized): diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index 1c0b806a..ca3331de 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -173,4 +173,5 @@ class LiveSetting(models.Model): ) self.error = LiveSettingValidator(live_setting=self).get_error() + super().save(*args, **kwargs) diff --git a/engine/apps/base/utils.py b/engine/apps/base/utils.py index 0f9f04b7..a3b5a657 100644 --- a/engine/apps/base/utils.py +++ b/engine/apps/base/utils.py @@ -1,16 +1,15 @@ import json import re -from urllib.parse import urljoin -import requests.exceptions from django.apps import apps -from django.conf import settings from python_http_client import UnauthorizedError from sendgrid import SendGridAPIClient from telegram import Bot from twilio.base.exceptions import TwilioException from twilio.rest import Client +from apps.oss_installation.models import CloudConnector + class LiveSettingProxy: def __dir__(self): @@ -98,18 +97,9 @@ class LiveSettingValidator: return f"Telegram error: {str(e)}" @classmethod - def _check_grafana_cloud_oncall_token(cls, grafan_oncall_token): - try: - info_url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/info/") - r = requests.get(info_url, headers={"AUTHORIZATION": grafan_oncall_token}, timeout=5) - if r.status_code == 200: - return - elif r.status_code == 403: - return f"Invalid token" - else: - return f"Non-200 HTTP code. Got {r.status_code}" - except requests.exceptions.RequestException as e: - return f"Error {str(e)}" + def _check_grafana_cloud_oncall_token(cls, grafana_oncall_token): + _, err = CloudConnector.sync_with_cloud(grafana_oncall_token) + return err @staticmethod def _is_email_valid(email): diff --git a/engine/apps/oss_installation/constants.py b/engine/apps/oss_installation/constants.py index db777bb3..11f3dc48 100644 --- a/engine/apps/oss_installation/constants.py +++ b/engine/apps/oss_installation/constants.py @@ -1,5 +1,3 @@ -CLOUD_URL = "https://a-prod-us-central-0.grafana.net/" - CLOUD_NOT_SYNCED = 0 CLOUD_SYNCED_USER_NOT_FOUND = 1 CLOUD_SYNCED_PHONE_NOT_VERIFIED = 2 diff --git a/engine/apps/oss_installation/models/__init__.py b/engine/apps/oss_installation/models/__init__.py index 2ee74128..beab1774 100644 --- a/engine/apps/oss_installation/models/__init__.py +++ b/engine/apps/oss_installation/models/__init__.py @@ -1,4 +1,4 @@ -from .cloud_organization_connector import CloudOrganizationConnector # noqa: F401 +from .cloud_connector import CloudConnector # noqa: F401 +from .cloud_heartbeat import CloudHeartbeat # noqa: F401 from .cloud_user_identity import CloudUserIdentity # noqa: F401 -from .heartbeat import CloudHeartbeat # noqa: F401 from .oss_installation import OssInstallation # noqa: F401 diff --git a/engine/apps/oss_installation/models/cloud_organization_connector.py b/engine/apps/oss_installation/models/cloud_connector.py similarity index 83% rename from engine/apps/oss_installation/models/cloud_organization_connector.py rename to engine/apps/oss_installation/models/cloud_connector.py index 126d9b65..1434b1ba 100644 --- a/engine/apps/oss_installation/models/cloud_organization_connector.py +++ b/engine/apps/oss_installation/models/cloud_connector.py @@ -5,50 +5,53 @@ import requests from django.db import models, transaction from apps.base.utils import live_settings -from apps.oss_installation.constants import CLOUD_URL +from apps.oss_installation.models import CloudHeartbeat from apps.oss_installation.models.cloud_user_identity import CloudUserIdentity from apps.user_management.models import User +from settings.base import GRAFANA_CLOUD_ONCALL_API_URL logger = logging.getLogger(__name__) -class CloudOrganizationConnector(models.Model): +class CloudConnector(models.Model): """ CloudOrganizationConnector model represents connection between oss organization and cloud organization. """ cloud_url = models.URLField() - organization = models.OneToOneField( - "user_management.organization", related_name="cloud_connector", on_delete=models.CASCADE - ) + # organization = models.OneToOneField( + # "user_management.organization", related_name="cloud_connector", on_delete=models.CASCADE + # ) @classmethod - def sync_with_cloud(cls, organization): + def sync_with_cloud(cls, token=None): """ sync_with_cloud sync organization with cloud organization defined by provided GRAFANA_CLOUD_ONCALL_TOKEN. """ sync_status = False error_msg = None - api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN + api_token = token or live_settings.GRAFANA_CLOUD_ONCALL_TOKEN if api_token is None: logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set") error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" else: - info_url = urljoin(CLOUD_URL, "api/v1/info/") + info_url = urljoin(GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/info/") try: r = requests.get(info_url, headers={"AUTHORIZATION": api_token}, timeout=5) if r.status_code == 200: - cls.objects.update_or_create(organization=organization, defaults={"cloud_url": r.json()["url"]}) - sync_status = True + connector = cls.objects.get_or_create() + connector.cloud_url = r.json()["url"] + connector.save() elif r.status_code == 403: logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is invalid") - error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is invalid" + error_msg = "Invalid token" else: error_msg = f"Non-200 HTTP code. Got {r.status_code}" except requests.exceptions.RequestException as e: logger.warning(f"Unable to sync with cloud. Request exception {str(e)}") error_msg = f"Unable to sync with cloud" + return sync_status, error_msg def sync_users_with_cloud(self) -> tuple[bool, str]: @@ -60,9 +63,9 @@ class CloudOrganizationConnector(models.Model): logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set") error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" - existing_emails = list(User.objects.filter(organization=self.organization).values_list("email", flat=True)) + existing_emails = list(User.objects.values_list("email", flat=True)) matching_users = [] - users_url = urljoin(CLOUD_URL, "api/v1/users") + users_url = urljoin(GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/users") fetch_next_page = True users_fetched = True @@ -98,11 +101,10 @@ class CloudOrganizationConnector(models.Model): cloud_id=user["id"], email=user["email"], phone_number_verified=user["is_phone_number_verified"], - organization=self.organization, ) ) - CloudUserIdentity.objects.filter(organization=self.organization).delete() + CloudUserIdentity.objects.delete() CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) return sync_status, error_msg @@ -116,7 +118,7 @@ class CloudOrganizationConnector(models.Model): logger.warning(f"Unable to sync_user_with cloud user_id {user.id}. GRAFANA_CLOUD_ONCALL_TOKEN is not set") error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" else: - url = urljoin(CLOUD_URL, f"api/v1/users/?email={user.email}") + url = urljoin(GRAFANA_CLOUD_ONCALL_API_URL, f"api/v1/users/?email={user.email}") try: r = requests.get(url, headers={"AUTHORIZATION": api_token}, timeout=5) if r.status_code != 200: @@ -132,7 +134,6 @@ class CloudOrganizationConnector(models.Model): CloudUserIdentity.objects.filter(email=user.emai).delete() CloudUserIdentity.objects.create( email=user.email, - organization=user.organization, phone_number_verified=cloud_used_data["is_phone_number_verified"], cloud_id=cloud_used_data["id"], ) @@ -147,3 +148,9 @@ class CloudOrganizationConnector(models.Model): error_msg = f"Unable to sync with cloud" return sync_status, error_msg + + @classmethod + def remove_sync(cls): + cls.objects.delete() + CloudUserIdentity.objects.delete() + CloudHeartbeat.objects.delete() diff --git a/engine/apps/oss_installation/models/heartbeat.py b/engine/apps/oss_installation/models/cloud_heartbeat.py similarity index 100% rename from engine/apps/oss_installation/models/heartbeat.py rename to engine/apps/oss_installation/models/cloud_heartbeat.py diff --git a/engine/apps/oss_installation/models/cloud_user_identity.py b/engine/apps/oss_installation/models/cloud_user_identity.py index fca0eebe..1918ddcb 100644 --- a/engine/apps/oss_installation/models/cloud_user_identity.py +++ b/engine/apps/oss_installation/models/cloud_user_identity.py @@ -5,9 +5,9 @@ class CloudUserIdentity(models.Model): phone_number_verified = models.BooleanField(default=False) cloud_id = models.CharField(max_length=20) email = models.EmailField() - organization = models.ForeignKey( - "user_management.Organization", on_delete=models.CASCADE, related_name="cloud_users" - ) - - class Meta: - unique_together = ("email", "organization") + # organization = models.ForeignKey( + # "user_management.Organization", on_delete=models.CASCADE, related_name="cloud_users" + # ) + # + # class Meta: + # unique_together = ("email", "organization") diff --git a/engine/apps/oss_installation/serializers/cloud_user.py b/engine/apps/oss_installation/serializers/cloud_user.py index 50b857b4..d8e35791 100644 --- a/engine/apps/oss_installation/serializers/cloud_user.py +++ b/engine/apps/oss_installation/serializers/cloud_user.py @@ -3,7 +3,7 @@ from urllib.parse import urljoin from rest_framework import serializers import apps.oss_installation.constants as cloud_constants -from apps.oss_installation.models import CloudOrganizationConnector, CloudUserIdentity +from apps.oss_installation.models import CloudConnector, CloudUserIdentity from apps.user_management.models import User @@ -17,9 +17,7 @@ class CloudUserSerializer(serializers.ModelSerializer): def get_cloud_data(self, obj): link = None status = cloud_constants.CLOUD_NOT_SYNCED - connector = CloudOrganizationConnector.objects.filter( - organization=self.context["request"].auth.organization - ).first() + connector = CloudConnector.objects.filter().first() if connector is not None: cloud_user_identity = CloudUserIdentity.objects.filter(email=obj.email).first() if cloud_user_identity is None: diff --git a/engine/apps/oss_installation/urls.py b/engine/apps/oss_installation/urls.py index 2a1e6d5f..25708249 100644 --- a/engine/apps/oss_installation/urls.py +++ b/engine/apps/oss_installation/urls.py @@ -2,10 +2,9 @@ from django.urls import path from common.api_helpers.optional_slash_router import optional_slash_path -from .views import CloudConnectionStatusView, CloudHeartbeatStatusView, CloudUsersView, CloudUserView +from .views import CloudConnectionView, CloudUsersView, CloudUserView urlpatterns = [ - optional_slash_path("cloud_heartbeat_status", CloudHeartbeatStatusView.as_view(), name="cloud_heartbeat_status"), optional_slash_path("cloud_users", CloudUsersView.as_view(), name="cloud-users-list"), path( "cloud_users/", @@ -16,5 +15,5 @@ urlpatterns = [ ), name="cloud-user-detail", ), - optional_slash_path("cloud_connection_status", CloudConnectionStatusView.as_view(), name="cloud-connection-status"), + optional_slash_path("cloud_connection", CloudConnectionView.as_view(), name="cloud-connection-status"), ] diff --git a/engine/apps/oss_installation/utils.py b/engine/apps/oss_installation/utils.py index fcfb537c..c0ca366c 100644 --- a/engine/apps/oss_installation/utils.py +++ b/engine/apps/oss_installation/utils.py @@ -1,19 +1,27 @@ +import logging from contextlib import suppress +from urllib.parse import urljoin +import requests +from django.apps import apps from django.utils import timezone -from apps.alerts.models import AlertGroupLogRecord, EscalationPolicy -from apps.base.models import UserNotificationPolicyLogRecord from apps.public_api.constants import DEMO_USER_ID from apps.schedules.ical_utils import list_users_to_notify_from_ical_for_period -from apps.schedules.models import OnCallSchedule -from apps.user_management.models import User +from settings.base import GRAFANA_CLOUD_ONCALL_API_URL + +logger = logging.getLogger(__name__) def active_oss_users_count(): """ active_oss_users_count returns count of active users of oss installation. """ + OnCallSchedule = apps.get_model("schedules", "OnCallSchedule") + AlertGroupLogRecord = apps.get_model("alerts", "AlertGroupLogRecord") + EscalationPolicy = apps.get_model("alerts", "EscalationPolicy") + UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") + User = apps.get_model("user_management", "User") # Take logs for previous 24 hours start = timezone.now() - timezone.timedelta(hours=24) @@ -68,3 +76,23 @@ def active_oss_users_count(): with suppress(KeyError): unique_active_users.remove(demo_user.pk) return len(unique_active_users) + + +def get_cloud_instance_info(api_token): + success = False + error_msg = None + r = None + info_url = urljoin(GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/info/") + try: + r = requests.get(info_url, headers={"AUTHORIZATION": api_token}, timeout=5) + if r.status_code == 200: + success = True + elif r.status_code == 403: + logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is invalid") + error_msg = "Invalid token" + else: + error_msg = f"Non-200 HTTP code. Got {r.status_code}" + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to sync with cloud. Request exception {str(e)}") + error_msg = f"Unable to sync with cloud" + return success, error_msg, r diff --git a/engine/apps/oss_installation/views/__init__.py b/engine/apps/oss_installation/views/__init__.py index 66a3be93..9cbe8980 100644 --- a/engine/apps/oss_installation/views/__init__.py +++ b/engine/apps/oss_installation/views/__init__.py @@ -1,3 +1,3 @@ -from .cloud_heartbeat_status import CloudHeartbeatStatusView # noqa: F401 -from .cloud_status import CloudConnectionStatusView # noqa: F401 +from .cloud_connection import CloudConnectionView # noqa: F401 +from .cloud_heartbeat import CloudHeartbeatStatusView # noqa: F401 from .cloud_users import CloudUsersView, CloudUserView # noqa: F401 diff --git a/engine/apps/oss_installation/views/cloud_connection.py b/engine/apps/oss_installation/views/cloud_connection.py new file mode 100644 index 00000000..cf8e4713 --- /dev/null +++ b/engine/apps/oss_installation/views/cloud_connection.py @@ -0,0 +1,35 @@ +from urllib.parse import urljoin + +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.api.permissions import IsAdmin +from apps.auth_token.auth import PluginAuthentication +from apps.base.utils import live_settings +from apps.oss_installation.models import CloudConnector, CloudHeartbeat + + +class CloudConnectionView(APIView): + authentication_classes = (PluginAuthentication,) + permission_classes = (IsAuthenticated, IsAdmin) + + def get(self, request): + connector = CloudConnector.objects.first() + heartbeat = CloudHeartbeat.objects.first() + response = { + "cloud_connection_status": connector is not None, + "token": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN, + "cloud_notifications_enabled": live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED, + "cloud_heartbeat_enabled": live_settings.GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED, + "cloud_heartbeat_link": self._get_heartbeat_link(connector, heartbeat), + "cloud_heartbeat_status": heartbeat is not None and heartbeat.success, + } + return Response(response) + + def _get_heartbeat_link(self, connector, heartbeat): + if connector is None: + return None + if heartbeat is None: + return None + return urljoin(connector.cloud_url, f"a/grafana-oncall-app/?page=integrations1&id={heartbeat.integration_id}") diff --git a/engine/apps/oss_installation/views/cloud_heartbeat_status.py b/engine/apps/oss_installation/views/cloud_heartbeat_status.py deleted file mode 100644 index be553641..00000000 --- a/engine/apps/oss_installation/views/cloud_heartbeat_status.py +++ /dev/null @@ -1,15 +0,0 @@ -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView - -from apps.auth_token.auth import PluginAuthentication -from apps.oss_installation.models import CloudHeartbeat - - -class CloudHeartbeatStatusView(APIView): - authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated,) - - def get(self, request): - response = {"status": CloudHeartbeat.status()} - return Response(response) diff --git a/engine/apps/oss_installation/views/cloud_status.py b/engine/apps/oss_installation/views/cloud_status.py deleted file mode 100644 index 825fa757..00000000 --- a/engine/apps/oss_installation/views/cloud_status.py +++ /dev/null @@ -1,19 +0,0 @@ -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView - -from apps.auth_token.auth import PluginAuthentication -from apps.oss_installation.models import CloudOrganizationConnector - - -class CloudConnectionStatusView(APIView): - authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated,) - - def get(self, request): - connector = CloudOrganizationConnector.objects.filter(organization=request.user.organization).first() - - response = { - "cloud_connection_status": connector is not None, - } - return Response(response) diff --git a/engine/apps/oss_installation/views/cloud_users.py b/engine/apps/oss_installation/views/cloud_users.py index d4bfd345..ab28c677 100644 --- a/engine/apps/oss_installation/views/cloud_users.py +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -9,7 +9,7 @@ from rest_framework.views import APIView import apps.oss_installation.constants as cloud_constants from apps.api.permissions import ActionPermission, IsAdmin, IsOwnerOrAdmin from apps.auth_token.auth import PluginAuthentication -from apps.oss_installation.models import CloudOrganizationConnector, CloudUserIdentity +from apps.oss_installation.models import CloudConnector, CloudUserIdentity from apps.oss_installation.serializers import CloudUserSerializer from apps.user_management.models import User from common.api_helpers.mixins import PublicPrimaryKeyMixin @@ -31,12 +31,12 @@ class CloudUsersView(HundredPageSizePaginator, APIView): results = self.paginate_queryset(queryset, request, view=self) emails = list(queryset.values_list("email", flat=True)) - cloud_identities = list(CloudUserIdentity.objects.filter(organization=organization, email__in=emails)) + cloud_identities = list(CloudUserIdentity.objects.filter(email__in=emails)) cloud_identities = {cloud_identity.email: cloud_identity for cloud_identity in cloud_identities} response = [] - connector = CloudOrganizationConnector.objects.filter(organization=organization) + connector = CloudConnector.objects.first() for user in results: link = None @@ -65,9 +65,7 @@ class CloudUsersView(HundredPageSizePaginator, APIView): return self.get_paginated_response(response) def post(self, request): - organization = request.user.organization - - connector = CloudOrganizationConnector.objects.filter(organization=organization) + connector = CloudConnector.objects.first() if connector is not None: sync_status, err = connector.sync_users_with_cloud() return Response(status=status.HTTP_200_OK, data={"status": sync_status, "error": err}) @@ -95,7 +93,7 @@ class CloudUserView( @action(detail=True, methods=["post"]) def sync_with_cloud(self, request, pk): user = self.get_object() - connector = CloudOrganizationConnector.objects.filter(organization=request["request"].auth.organization).first() + connector = CloudConnector.objects.first() if connector is not None: sync_status, err = connector.sync_user_with_cloud(user) return Response(status=status.HTTP_200_OK, data={"status": sync_status, "error": err}) diff --git a/engine/engine/urls.py b/engine/engine/urls.py index 9e55241a..702e2907 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -54,7 +54,7 @@ if settings.FEATURE_SLACK_INTEGRATION_ENABLED: path("slack/", include("apps.slack.urls")), ] -if settings.OSS_INSTALLATION_FEATURES_ENABLED: +if settings.OSS_INSTALLATION_FEATURES_ENABLED or True: urlpatterns += [ path("api/internal/v1/", include("apps.oss_installation.urls")), ] diff --git a/engine/settings/base.py b/engine/settings/base.py index 9bb227f9..281a8a86 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -409,7 +409,7 @@ SELF_HOSTED_SETTINGS = { "ORG_TITLE": "Self-Hosted Organization", } -GRAFANA_CLOUD_ONCALL_API_URL = os.environ.get("GRAFANA_CLOUD_ONCALL_API_URL", "https://a-prod-us-central-0.grafana.net") +GRAFANA_CLOUD_ONCALL_API_URL = "https://a-02-dev-us-central-0.grafana.net/" GRAFANA_CLOUD_ONCALL_TOKEN = os.environ.get("GRAFANA_CLOUD_ONCALL_TOKEN", None) GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True) GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", default=True)