From cfa7fb816c53c3d8626027753696873289356fa8 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Tue, 24 Jan 2023 13:44:07 +0800 Subject: [PATCH] Sync users and teams on tf requests (#1180) # What this PR does This PR add sync with grafana on requests from terraform ## Which issue(s) this PR fixes It's needed to fix case when customers want to create team via grafana terraform provider and use it in the oncall provider without having to log into Grafana Cloud. Co-authored-by: Joey Orlando --- CHANGELOG.md | 1 + engine/apps/grafana_plugin/helpers/client.py | 25 ++--- engine/apps/grafana_plugin/tasks/__init__.py | 6 +- engine/apps/grafana_plugin/tasks/sync.py | 15 ++- engine/apps/public_api/tf_sync.py | 35 +++++++ engine/apps/public_api/views/teams.py | 3 + engine/apps/public_api/views/users.py | 3 + engine/apps/user_management/sync.py | 33 +++++-- .../apps/user_management/tests/test_sync.py | 98 +++++++++++++++++-- .../oncall_gateway/oncall_gateway_client.py | 3 - 10 files changed, 190 insertions(+), 32 deletions(-) create mode 100644 engine/apps/public_api/tf_sync.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 97e96543..8beadadb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add Slack slash command allowing to trigger a direct page via a manually created alert group +- Add sync with grafana on /users and /teams api calls from terraform plugin ### Changed diff --git a/engine/apps/grafana_plugin/helpers/client.py b/engine/apps/grafana_plugin/helpers/client.py index b742d92c..edad4812 100644 --- a/engine/apps/grafana_plugin/helpers/client.py +++ b/engine/apps/grafana_plugin/helpers/client.py @@ -54,16 +54,16 @@ class APIClient: self.api_url = api_url self.api_token = api_token - def api_head(self, endpoint: str, body: dict = None) -> Tuple[Optional[Response], dict]: - return self.call_api(endpoint, requests.head, body) + def api_head(self, endpoint: str, body: dict = None, **kwargs) -> Tuple[Optional[Response], dict]: + return self.call_api(endpoint, requests.head, body, **kwargs) - def api_get(self, endpoint: str) -> Tuple[Optional[Response], dict]: - return self.call_api(endpoint, requests.get) + def api_get(self, endpoint: str, **kwargs) -> Tuple[Optional[Response], dict]: + return self.call_api(endpoint, requests.get, **kwargs) - def api_post(self, endpoint: str, body: dict = None) -> Tuple[Optional[Response], dict]: - return self.call_api(endpoint, requests.post, body) + def api_post(self, endpoint: str, body: dict = None, **kwargs) -> Tuple[Optional[Response], dict]: + return self.call_api(endpoint, requests.post, body, **kwargs) - def call_api(self, endpoint: str, http_method, body: dict = None) -> Tuple[Optional[Response], dict]: + def call_api(self, endpoint: str, http_method, body: dict = None, **kwargs) -> Tuple[Optional[Response], dict]: request_start = time.perf_counter() call_status = { "url": urljoin(self.api_url, endpoint), @@ -72,7 +72,7 @@ class APIClient: "message": "", } try: - response = http_method(call_status["url"], json=body, headers=self.request_headers) + response = http_method(call_status["url"], json=body, headers=self.request_headers, **kwargs) call_status["status_code"] = response.status_code response.raise_for_status() @@ -89,6 +89,7 @@ class APIClient: requests.exceptions.ConnectionError, requests.exceptions.HTTPError, requests.exceptions.TooManyRedirects, + requests.exceptions.Timeout, json.JSONDecodeError, ) as e: logger.warning("Error connecting to api instance " + str(e)) @@ -153,8 +154,8 @@ class GrafanaAPIClient(APIClient): _, resp_status = self.api_head(self.USER_PERMISSION_ENDPOINT) return resp_status["status_code"] == status.HTTP_200_OK - def get_users(self, rbac_is_enabled_for_org: bool) -> List[GrafanaUserWithPermissions]: - users, _ = self.api_get("api/org/users") + def get_users(self, rbac_is_enabled_for_org: bool, **kwargs) -> List[GrafanaUserWithPermissions]: + users, _ = self.api_get("api/org/users", **kwargs) if not users: return [] @@ -166,8 +167,8 @@ class GrafanaAPIClient(APIClient): user["permissions"] = user_permissions.get(str(user["userId"]), []) return users - def get_teams(self): - return self.api_get("api/teams/search?perpage=1000000") + def get_teams(self, **kwargs): + return self.api_get("api/teams/search?perpage=1000000", **kwargs) def get_team_members(self, team_id): return self.api_get(f"api/teams/{team_id}/members") diff --git a/engine/apps/grafana_plugin/tasks/__init__.py b/engine/apps/grafana_plugin/tasks/__init__.py index 8ba4f62e..049a25bb 100644 --- a/engine/apps/grafana_plugin/tasks/__init__.py +++ b/engine/apps/grafana_plugin/tasks/__init__.py @@ -1 +1,5 @@ -from .sync import start_sync_organizations, sync_organization_async # noqa: F401 +from .sync import ( # noqa: F401 + start_sync_organizations, + sync_organization_async, + sync_team_members_for_organization_async, +) diff --git a/engine/apps/grafana_plugin/tasks/sync.py b/engine/apps/grafana_plugin/tasks/sync.py index 204797d9..6c205d7b 100644 --- a/engine/apps/grafana_plugin/tasks/sync.py +++ b/engine/apps/grafana_plugin/tasks/sync.py @@ -5,10 +5,11 @@ from django.conf import settings from django.utils import timezone from apps.grafana_plugin.helpers import GcomAPIClient +from apps.grafana_plugin.helpers.client import GrafanaAPIClient from apps.grafana_plugin.helpers.gcom import get_active_instance_ids, get_deleted_instance_ids, get_stack_regions from apps.user_management.models import Organization from apps.user_management.models.region import sync_regions -from apps.user_management.sync import cleanup_organization, sync_organization +from apps.user_management.sync import cleanup_organization, sync_organization, sync_team_members from common.custom_celery_tasks import shared_dedicated_queue_retry_task logger = get_task_logger(__name__) @@ -117,3 +118,15 @@ def start_sync_regions(): return sync_regions(regions) + + +@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), max_retries=1) +def sync_team_members_for_organization_async(organization_pk): + try: + organization = Organization.objects.get(pk=organization_pk) + except Organization.DoesNotExist: + logger.info(f"Organization {organization_pk} was not found") + return + + grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token) + sync_team_members(grafana_api_client, organization) diff --git a/engine/apps/public_api/tf_sync.py b/engine/apps/public_api/tf_sync.py new file mode 100644 index 00000000..dfde226f --- /dev/null +++ b/engine/apps/public_api/tf_sync.py @@ -0,0 +1,35 @@ +import logging + +from django.core.cache import cache + +from apps.grafana_plugin.helpers.client import GrafanaAPIClient +from apps.grafana_plugin.tasks import sync_team_members_for_organization_async +from apps.user_management.sync import sync_teams, sync_users + +logger = logging.getLogger(__name__) + +SYNC_REQUEST_TIMEOUT = 5 +SYNC_PERIOD = 60 + + +def is_request_from_terraform(request) -> bool: + return "terraform-provider-grafana" in request.META.get("HTTP_USER_AGENT", "") + + +def sync_users_on_tf_request(organization): + cache_key = f"sync_users_on_tf_request_{organization.id}" + if not cache.get(cache_key): + logger.info(f"Start sync_users_on_tf_request organization_id={organization.id}") + client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token) + cache.set(cache_key, True, SYNC_PERIOD) + sync_users(client, organization, timeout=SYNC_REQUEST_TIMEOUT) + + +def sync_teams_on_tf_request(organization): + cache_key = f"sync_teams_on_tf_request_{organization.id}" + if not cache.get(cache_key): + logger.info(f"Start sync_teams_on_tf_request organization_id={organization.id}") + cache.set(cache_key, True, SYNC_PERIOD) + client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token) + sync_teams(client, organization, timeout=SYNC_REQUEST_TIMEOUT) + sync_team_members_for_organization_async.apply_async((organization.id,)) diff --git a/engine/apps/public_api/views/teams.py b/engine/apps/public_api/views/teams.py index c42e0460..40eb0e87 100644 --- a/engine/apps/public_api/views/teams.py +++ b/engine/apps/public_api/views/teams.py @@ -4,6 +4,7 @@ from rest_framework.permissions import IsAuthenticated from apps.auth_token.auth import ApiTokenAuthentication from apps.public_api.serializers.teams import TeamSerializer +from apps.public_api.tf_sync import is_request_from_terraform, sync_teams_on_tf_request from apps.public_api.throttlers.user_throttle import UserThrottle from apps.user_management.models import Team from common.api_helpers.mixins import PublicPrimaryKeyMixin @@ -20,6 +21,8 @@ class TeamView(PublicPrimaryKeyMixin, RetrieveModelMixin, ListModelMixin, viewse throttle_classes = [UserThrottle] def get_queryset(self): + if is_request_from_terraform(self.request): + sync_teams_on_tf_request(self.request.auth.organization) name = self.request.query_params.get("name", None) queryset = self.request.auth.organization.teams.all() if name: diff --git a/engine/apps/public_api/views/users.py b/engine/apps/public_api/views/users.py index 1eed6e1d..bba5484e 100644 --- a/engine/apps/public_api/views/users.py +++ b/engine/apps/public_api/views/users.py @@ -9,6 +9,7 @@ from apps.api.permissions import LegacyAccessControlRole from apps.auth_token.auth import ApiTokenAuthentication, UserScheduleExportAuthentication from apps.public_api.custom_renderers import CalendarRenderer from apps.public_api.serializers import FastUserSerializer, UserSerializer +from apps.public_api.tf_sync import is_request_from_terraform, sync_users_on_tf_request from apps.public_api.throttlers.user_throttle import UserThrottle from apps.schedules.ical_utils import user_ical_export from apps.schedules.models import OnCallSchedule @@ -48,6 +49,8 @@ class UserView(RateLimitHeadersMixin, ShortSerializerMixin, ReadOnlyModelViewSet throttle_classes = [UserThrottle] def get_queryset(self): + if is_request_from_terraform(self.request): + sync_users_on_tf_request(self.request.auth.organization) is_short_request = self.request.query_params.get("short", "false") == "true" queryset = self.request.auth.organization.users.all() if not is_short_request: diff --git a/engine/apps/user_management/sync.py b/engine/apps/user_management/sync.py index 9d2b907d..01dfabf8 100644 --- a/engine/apps/user_management/sync.py +++ b/engine/apps/user_management/sync.py @@ -30,11 +30,11 @@ def sync_organization(organization): _sync_instance_info(organization) - api_users = grafana_api_client.get_users(rbac_is_enabled) - - if api_users: + _, check_token_call_status = grafana_api_client.check_token() + if check_token_call_status["status_code"] == 200: organization.api_token_status = Organization.API_TOKEN_STATUS_OK - sync_users_and_teams(grafana_api_client, api_users, organization) + sync_users_and_teams(grafana_api_client, organization) + organization.last_time_synced = timezone.now() organization.is_grafana_incident_enabled = check_grafana_incident_is_enabled(grafana_api_client) else: organization.api_token_status = Organization.API_TOKEN_STATUS_FAILED @@ -71,27 +71,42 @@ def _sync_instance_info(organization): organization.gcom_token_org_last_time_synced = timezone.now() -def sync_users_and_teams(client, api_users, organization): +def sync_users_and_teams(client: GrafanaAPIClient, organization): + sync_users(client, organization) + sync_teams(client, organization) + sync_team_members(client, organization) + + +def sync_users(client: GrafanaAPIClient, organization, **kwargs): + api_users = client.get_users(organization.is_rbac_permissions_enabled, **kwargs) # check if api_users are shaped correctly. e.g. for paused instance, the response is not a list. if not api_users or not isinstance(api_users, (tuple, list)): return - User.objects.sync_for_organization(organization=organization, api_users=api_users) - api_teams_result, _ = client.get_teams() + +def sync_teams(client: GrafanaAPIClient, organization, **kwargs): + api_teams_result, _ = client.get_teams(**kwargs) if not api_teams_result: return - api_teams = api_teams_result["teams"] Team.objects.sync_for_organization(organization=organization, api_teams=api_teams) + +def sync_team_members(client: GrafanaAPIClient, organization): for team in organization.teams.all(): members, _ = client.get_team_members(team.team_id) if not members: continue User.objects.sync_for_team(team=team, api_members=members) - organization.last_time_synced = timezone.now() + +def sync_users_for_teams(client: GrafanaAPIClient, organization, **kwargs): + api_teams_result, _ = client.get_teams(**kwargs) + if not api_teams_result: + return + api_teams = api_teams_result["teams"] + Team.objects.sync_for_organization(organization=organization, api_teams=api_teams) def check_grafana_incident_is_enabled(client): diff --git a/engine/apps/user_management/tests/test_sync.py b/engine/apps/user_management/tests/test_sync.py index 40fc1223..2b9dddd5 100644 --- a/engine/apps/user_management/tests/test_sync.py +++ b/engine/apps/user_management/tests/test_sync.py @@ -134,14 +134,19 @@ def test_sync_organization(make_organization, make_team, make_user_for_organizat }, ) + api_check_token_call_status = {"status_code": 200} + with patch.object(GrafanaAPIClient, "is_rbac_enabled_for_organization", return_value=False): with patch.object(GrafanaAPIClient, "get_users", return_value=api_users_response): with patch.object(GrafanaAPIClient, "get_teams", return_value=(api_teams_response, None)): with patch.object(GrafanaAPIClient, "get_team_members", return_value=(api_members_response, None)): with patch.object( - GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None) + GrafanaAPIClient, "check_token", return_value=(None, api_check_token_call_status) ): - sync_organization(organization) + with patch.object( + GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None) + ): + sync_organization(organization) # check that users are populated assert organization.users.count() == 1 @@ -167,9 +172,50 @@ def test_sync_organization(make_organization, make_team, make_user_for_organizat def test_sync_organization_is_rbac_permissions_enabled_open_source(make_organization, grafana_api_response): organization = make_organization() + api_users_response = ( + { + "userId": 1, + "email": "test@test.test", + "name": "Test", + "login": "test", + "role": "admin", + "avatarUrl": "test.test/test", + "permissions": [], + }, + ) + + api_teams_response = { + "totalCount": 1, + "teams": ( + { + "id": 1, + "name": "Test", + "email": "test@test.test", + "avatarUrl": "test.test/test", + }, + ), + } + + api_members_response = ( + { + "orgId": organization.org_id, + "teamId": 1, + "userId": 1, + }, + ) + api_check_token_call_status = {"status_code": 200} + with patch.object(GrafanaAPIClient, "is_rbac_enabled_for_organization", return_value=grafana_api_response): - with patch.object(GrafanaAPIClient, "get_users", return_value=[]): - sync_organization(organization) + with patch.object(GrafanaAPIClient, "get_users", return_value=api_users_response): + with patch.object(GrafanaAPIClient, "get_teams", return_value=(api_teams_response, None)): + with patch.object(GrafanaAPIClient, "get_team_members", return_value=(api_members_response, None)): + with patch.object( + GrafanaAPIClient, "check_token", return_value=(None, api_check_token_call_status) + ): + with patch.object( + GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None) + ): + sync_organization(organization) organization.refresh_from_db() assert organization.is_rbac_permissions_enabled == grafana_api_response @@ -184,10 +230,50 @@ def test_sync_organization_is_rbac_permissions_enabled_cloud(mocked_gcom_client, stack_id = 5 organization = make_organization(stack_id=stack_id) + api_check_token_call_status = {"status_code": 200} + mocked_gcom_client.return_value.is_rbac_enabled_for_stack.return_value = gcom_api_response - with patch.object(GrafanaAPIClient, "get_users", return_value=[]): - sync_organization(organization) + api_users_response = ( + { + "userId": 1, + "email": "test@test.test", + "name": "Test", + "login": "test", + "role": "admin", + "avatarUrl": "test.test/test", + "permissions": [], + }, + ) + + api_teams_response = { + "totalCount": 1, + "teams": ( + { + "id": 1, + "name": "Test", + "email": "test@test.test", + "avatarUrl": "test.test/test", + }, + ), + } + + api_members_response = ( + { + "orgId": organization.org_id, + "teamId": 1, + "userId": 1, + }, + ) + + with patch.object(GrafanaAPIClient, "check_token", return_value=(None, api_check_token_call_status)): + with patch.object(GrafanaAPIClient, "get_users", return_value=api_users_response): + with patch.object(GrafanaAPIClient, "get_teams", return_value=(api_teams_response, None)): + with patch.object(GrafanaAPIClient, "get_team_members", return_value=(api_members_response, None)): + with patch.object( + GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None) + ): + sync_organization(organization) organization.refresh_from_db() diff --git a/engine/common/oncall_gateway/oncall_gateway_client.py b/engine/common/oncall_gateway/oncall_gateway_client.py index 77af439d..9b8c03b5 100644 --- a/engine/common/oncall_gateway/oncall_gateway_client.py +++ b/engine/common/oncall_gateway/oncall_gateway_client.py @@ -129,11 +129,8 @@ class OnCallGatewayAPIClient: 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: - print(1) err_msg = "%s Client Error: %s for url: %s" % (response.status_code, err_msg, response.url) - elif 500 <= response.status_code < 600: - print(2) err_msg = "%s Server Error: %s for url: %s" % (response.status_code, err_msg, response.url) print(err_msg) raise requests.exceptions.HTTPError(err_msg, response=response)