diff --git a/awx/api/urls/analytics.py b/awx/api/urls/analytics.py new file mode 100644 index 0000000000..c8601c881d --- /dev/null +++ b/awx/api/urls/analytics.py @@ -0,0 +1,31 @@ +# Copyright (c) 2017 Ansible, Inc. +# All Rights Reserved. + +from django.urls import re_path + +import awx.api.views.analytics as analytics + + +urls = [ + re_path(r'^$', analytics.AnalyticsRootView.as_view(), name='analytics_root_view'), + re_path(r'^authorized/$', analytics.AnalyticsAuthorizedView.as_view(), name='analytics_authorized'), + re_path(r'^reports/$', analytics.AnalyticsReportsList.as_view(), name='analytics_reports_list'), + re_path(r'^report/(?P[\w-]+)/$', analytics.AnalyticsReportDetail.as_view(), name='analytics_report_detail'), + re_path(r'^report_options/$', analytics.AnalyticsReportOptionsList.as_view(), name='analytics_report_options_list'), + re_path(r'^adoption_rate/$', analytics.AnalyticsAdoptionRateList.as_view(), name='analytics_adoption_rate'), + re_path(r'^adoption_rate_options/$', analytics.AnalyticsAdoptionRateList.as_view(), name='analytics_adoption_rate_options'), + re_path(r'^event_explorer/$', analytics.AnalyticsEventExplorerList.as_view(), name='analytics_event_explorer'), + re_path(r'^event_explorer_options/$', analytics.AnalyticsEventExplorerList.as_view(), name='analytics_event_explorer_options'), + re_path(r'^host_explorer/$', analytics.AnalyticsHostExplorerList.as_view(), name='analytics_host_explorer'), + re_path(r'^host_explorer_options/$', analytics.AnalyticsHostExplorerList.as_view(), name='analytics_host_explorer_options'), + re_path(r'^job_explorer/$', analytics.AnalyticsJobExplorerList.as_view(), name='analytics_job_explorer'), + re_path(r'^job_explorer_options/$', analytics.AnalyticsJobExplorerList.as_view(), name='analytics_job_explorer_options'), + re_path(r'^probe_templates/$', analytics.AnalyticsProbeTemplatesList.as_view(), name='analytics_probe_templates_explorer'), + re_path(r'^probe_templates_options/$', analytics.AnalyticsProbeTemplatesList.as_view(), name='analytics_probe_templates_options'), + re_path(r'^probe_template_for_hosts/$', analytics.AnalyticsProbeTemplateForHostsList.as_view(), name='analytics_probe_template_for_hosts_explorer'), + re_path(r'^probe_template_for_hosts_options/$', analytics.AnalyticsProbeTemplateForHostsList.as_view(), name='analytics_probe_template_for_hosts_options'), + re_path(r'^roi_templates/$', analytics.AnalyticsRoiTemplatesList.as_view(), name='analytics_roi_templates_explorer'), + re_path(r'^roi_templates_options/$', analytics.AnalyticsRoiTemplatesList.as_view(), name='analytics_roi_templates_options'), +] + +__all__ = ['urls'] diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index bb27710dcc..94198f3766 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -42,6 +42,7 @@ from awx.api.views.bulk import ( from awx.api.views.mesh_visualizer import MeshVisualizer from awx.api.views.metrics import MetricsView +from awx.api.views.analytics import AWX_ANALYTICS_API_PREFIX from .organization import urls as organization_urls from .user import urls as user_urls @@ -82,7 +83,7 @@ from .oauth2 import urls as oauth2_urls from .oauth2_root import urls as oauth2_root_urls from .workflow_approval_template import urls as workflow_approval_template_urls from .workflow_approval import urls as workflow_approval_urls - +from .analytics import urls as analytics_urls v2_urls = [ re_path(r'^$', ApiV2RootView.as_view(), name='api_v2_root_view'), @@ -147,6 +148,7 @@ v2_urls = [ re_path(r'^unified_job_templates/$', UnifiedJobTemplateList.as_view(), name='unified_job_template_list'), re_path(r'^unified_jobs/$', UnifiedJobList.as_view(), name='unified_job_list'), re_path(r'^activity_stream/', include(activity_stream_urls)), + re_path(rf'^{AWX_ANALYTICS_API_PREFIX}/', include(analytics_urls)), re_path(r'^workflow_approval_templates/', include(workflow_approval_template_urls)), re_path(r'^workflow_approvals/', include(workflow_approval_urls)), re_path(r'^bulk/$', BulkView.as_view(), name='bulk'), diff --git a/awx/api/views/analytics.py b/awx/api/views/analytics.py new file mode 100644 index 0000000000..e7c50ad5b9 --- /dev/null +++ b/awx/api/views/analytics.py @@ -0,0 +1,297 @@ +import requests +import logging +import urllib.parse as urlparse + +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from django.utils import translation + +from awx.api.generics import APIView, Response +from awx.api.permissions import IsSystemAdminOrAuditor +from awx.api.versioning import reverse +from awx.main.utils import get_awx_version +from rest_framework.permissions import AllowAny +from rest_framework import status + +from collections import OrderedDict + +AUTOMATION_ANALYTICS_API_URL_PATH = "/api/tower-analytics/v1" +AWX_ANALYTICS_API_PREFIX = 'analytics' + +ERROR_UPLOAD_NOT_ENABLED = "analytics-upload-not-enabled" +ERROR_MISSING_URL = "missing-url" +ERROR_MISSING_USER = "missing-user" +ERROR_MISSING_PASSWORD = "missing-password" +ERROR_NO_DATA_OR_ENTITLEMENT = "no-data-or-entitlement" +ERROR_NOT_FOUND = "not-found" +ERROR_UNAUTHORIZED = "unauthorized" +ERROR_UNKNOWN = "unknown" +ERROR_UNSUPPORTED_METHOD = "unsupported-method" + +logger = logging.getLogger('awx.api.views.analytics') + + +class MissingSettings(Exception): + """Settings are not correct Exception""" + + pass + + +class GetNotAllowedMixin(object): + def get(self, request, format=None): + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) + + +class AnalyticsRootView(APIView): + permission_classes = (AllowAny,) + name = _('Automation Analytics') + swagger_topic = 'Automation Analytics' + + def get(self, request, format=None): + data = OrderedDict() + data['authorized'] = reverse('api:analytics_authorized') + data['reports'] = reverse('api:analytics_reports_list') + data['report_options'] = reverse('api:analytics_report_options_list') + data['adoption_rate'] = reverse('api:analytics_adoption_rate') + data['adoption_rate_options'] = reverse('api:analytics_adoption_rate_options') + data['event_explorer'] = reverse('api:analytics_event_explorer') + data['event_explorer_options'] = reverse('api:analytics_event_explorer_options') + data['host_explorer'] = reverse('api:analytics_host_explorer') + data['host_explorer_options'] = reverse('api:analytics_host_explorer_options') + data['job_explorer'] = reverse('api:analytics_job_explorer') + data['job_explorer_options'] = reverse('api:analytics_job_explorer_options') + data['probe_templates'] = reverse('api:analytics_probe_templates_explorer') + data['probe_templates_options'] = reverse('api:analytics_probe_templates_options') + data['probe_template_for_hosts'] = reverse('api:analytics_probe_template_for_hosts_explorer') + data['probe_template_for_hosts_options'] = reverse('api:analytics_probe_template_for_hosts_options') + data['roi_templates'] = reverse('api:analytics_roi_templates_explorer') + data['roi_templates_options'] = reverse('api:analytics_roi_templates_options') + return Response(data) + + +class AnalyticsGenericView(APIView): + """ + Example: + headers = { + 'Content-Type': 'application/json', + } + + params = { + 'limit': '20', + 'offset': '0', + 'sort_by': 'name:asc', + } + + json_data = { + 'limit': '20', + 'offset': '0', + 'sort_options': 'name', + 'sort_order': 'asc', + 'tags': [], + 'slug': [], + 'name': [], + 'description': '', + } + + response = requests.post(f'{AUTOMATION_ANALYTICS_API_URL}/reports/', params=params, + headers=headers, json=json_data) + + return Response(response.json(), status=response.status_code) + """ + + permission_classes = (IsSystemAdminOrAuditor,) + + @staticmethod + def _request_headers(request): + headers = {} + for header in ['Content-Type', 'Content-Length', 'Accept-Encoding', 'User-Agent', 'Accept']: + if request.headers.get(header, None): + headers[header] = request.headers.get(header) + headers['X-Rh-Analytics-Source'] = 'controller' + headers['X-Rh-Analytics-Source-Version'] = get_awx_version() + headers['Accept-Language'] = translation.get_language() + + return headers + + @staticmethod + def _get_analytics_path(request_path): + parts = request_path.split(f'{AWX_ANALYTICS_API_PREFIX}/') + path_specific = parts[-1] + return f"{AUTOMATION_ANALYTICS_API_URL_PATH}/{path_specific}" + + def _get_analytics_url(self, request_path): + analytics_path = self._get_analytics_path(request_path) + url = getattr(settings, 'AUTOMATION_ANALYTICS_URL', None) + if not url: + raise MissingSettings(ERROR_MISSING_URL) + url_parts = urlparse.urlsplit(url) + analytics_url = urlparse.urlunsplit([url_parts.scheme, url_parts.netloc, analytics_path, url_parts.query, url_parts.fragment]) + return analytics_url + + @staticmethod + def _get_setting(setting_name, default, error_message): + setting = getattr(settings, setting_name, default) + if not setting: + raise MissingSettings(error_message) + return setting + + @staticmethod + def _error_response(keyword, message=None, remote=True, remote_status_code=None, status_code=status.HTTP_403_FORBIDDEN): + text = {"error": {"remote": remote, "remote_status": remote_status_code, "keyword": keyword}} + if message: + text["error"]["message"] = message + return Response(text, status=status_code) + + def _error_response_404(self, response): + try: + json_response = response.json() + # Subscription/entitlement problem or missing tenant data in AA db => HTTP 403 + message = json_response.get('error', None) + if message: + return self._error_response(ERROR_NO_DATA_OR_ENTITLEMENT, message, remote=True, remote_status_code=response.status_code) + + # Standard 404 problem => HTTP 404 + message = json_response.get('detail', None) or response.text + except requests.exceptions.JSONDecodeError: + # Unexpected text => still HTTP 404 + message = response.text + + return self._error_response(ERROR_NOT_FOUND, message, remote=True, remote_status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND) + + @staticmethod + def _update_response_links(json_response): + if not json_response.get('links', None): + return + + for key, value in json_response['links'].items(): + if value: + json_response['links'][key] = value.replace(AUTOMATION_ANALYTICS_API_URL_PATH, f"/api/v2/{AWX_ANALYTICS_API_PREFIX}") + + def _forward_response(self, response): + try: + content_type = response.headers.get('content-type', '') + if content_type.find('application/json') != -1: + json_response = response.json() + self._update_response_links(json_response) + + return Response(json_response, status=response.status_code) + except Exception as e: + logger.error(f"Analytics API: Response error: {e}") + + return Response(response.content, status=response.status_code) + + def _send_to_analytics(self, request, method): + try: + headers = self._request_headers(request) + + self._get_setting('INSIGHTS_TRACKING_STATE', False, ERROR_UPLOAD_NOT_ENABLED) + url = self._get_analytics_url(request.path) + rh_user = self._get_setting('REDHAT_USERNAME', None, ERROR_MISSING_USER) + rh_password = self._get_setting('REDHAT_PASSWORD', None, ERROR_MISSING_PASSWORD) + + if method not in ["GET", "POST", "OPTIONS"]: + return self._error_response(ERROR_UNSUPPORTED_METHOD, method, remote=False, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + else: + response = requests.request( + method, + url, + auth=(rh_user, rh_password), + verify=settings.INSIGHTS_CERT_PATH, + params=request.query_params, + headers=headers, + json=request.data, + timeout=(31, 31), + ) + # + # Missing or wrong user/pass + # + if response.status_code == status.HTTP_401_UNAUTHORIZED: + text = (response.text or '').rstrip("\n") + return self._error_response(ERROR_UNAUTHORIZED, text, remote=True, remote_status_code=response.status_code) + # + # Not found, No entitlement or No data in Analytics + # + elif response.status_code == status.HTTP_404_NOT_FOUND: + return self._error_response_404(response) + # + # Success or not a 401/404 errors are just forwarded + # + else: + return self._forward_response(response) + + except MissingSettings as e: + logger.warning(f"Analytics API: Setting missing: {e.args[0]}") + return self._error_response(e.args[0], remote=False) + except requests.exceptions.RequestException as e: + logger.error(f"Analytics API: Request error: {e}") + return self._error_response(ERROR_UNKNOWN, str(e), remote=False, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + except Exception as e: + logger.error(f"Analytics API: Error: {e}") + return self._error_response(ERROR_UNKNOWN, str(e), remote=False, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class AnalyticsGenericListView(AnalyticsGenericView): + def get(self, request, format=None): + return self._send_to_analytics(request, method="GET") + + def post(self, request, format=None): + return self._send_to_analytics(request, method="POST") + + def options(self, request, format=None): + return self._send_to_analytics(request, method="OPTIONS") + + +class AnalyticsGenericDetailView(AnalyticsGenericView): + def get(self, request, slug, format=None): + return self._send_to_analytics(request, method="GET") + + def post(self, request, slug, format=None): + return self._send_to_analytics(request, method="POST") + + def options(self, request, slug, format=None): + return self._send_to_analytics(request, method="OPTIONS") + + +class AnalyticsAuthorizedView(AnalyticsGenericListView): + name = _("Authorized") + + +class AnalyticsReportsList(GetNotAllowedMixin, AnalyticsGenericListView): + name = _("Reports") + swagger_topic = "Automation Analytics" + + +class AnalyticsReportDetail(AnalyticsGenericDetailView): + name = _("Report") + + +class AnalyticsReportOptionsList(AnalyticsGenericListView): + name = _("Report Options") + + +class AnalyticsAdoptionRateList(GetNotAllowedMixin, AnalyticsGenericListView): + name = _("Adoption Rate") + + +class AnalyticsEventExplorerList(GetNotAllowedMixin, AnalyticsGenericListView): + name = _("Event Explorer") + + +class AnalyticsHostExplorerList(GetNotAllowedMixin, AnalyticsGenericListView): + name = _("Host Explorer") + + +class AnalyticsJobExplorerList(GetNotAllowedMixin, AnalyticsGenericListView): + name = _("Job Explorer") + + +class AnalyticsProbeTemplatesList(GetNotAllowedMixin, AnalyticsGenericListView): + name = _("Probe Templates") + + +class AnalyticsProbeTemplateForHostsList(GetNotAllowedMixin, AnalyticsGenericListView): + name = _("Probe Template For Hosts") + + +class AnalyticsRoiTemplatesList(GetNotAllowedMixin, AnalyticsGenericListView): + name = _("ROI Templates") diff --git a/awx/api/views/root.py b/awx/api/views/root.py index 7f33fac4af..4f65b01a15 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -126,6 +126,7 @@ class ApiVersionRootView(APIView): data['workflow_job_nodes'] = reverse('api:workflow_job_node_list', request=request) data['mesh_visualizer'] = reverse('api:mesh_visualizer_view', request=request) data['bulk'] = reverse('api:bulk', request=request) + data['analytics'] = reverse('api:analytics_root_view', request=request) return Response(data) diff --git a/awx/main/analytics/core.py b/awx/main/analytics/core.py index 9cbc873b2b..b2e667ed2c 100644 --- a/awx/main/analytics/core.py +++ b/awx/main/analytics/core.py @@ -375,9 +375,7 @@ def ship(path): s.headers = get_awx_http_client_headers() s.headers.pop('Content-Type') with set_environ(**settings.AWX_TASK_ENV): - response = s.post( - url, files=files, verify="/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", auth=(rh_user, rh_password), headers=s.headers, timeout=(31, 31) - ) + response = s.post(url, files=files, verify=settings.INSIGHTS_CERT_PATH, auth=(rh_user, rh_password), headers=s.headers, timeout=(31, 31)) # Accept 2XX status_codes if response.status_code >= 300: logger.error('Upload failed with status {}, {}'.format(response.status_code, response.text)) diff --git a/awx/main/tests/functional/api/test_analytics.py b/awx/main/tests/functional/api/test_analytics.py new file mode 100644 index 0000000000..0c11a1a3ff --- /dev/null +++ b/awx/main/tests/functional/api/test_analytics.py @@ -0,0 +1,86 @@ +import pytest +import requests +from awx.api.views.analytics import AnalyticsGenericView, MissingSettings, AUTOMATION_ANALYTICS_API_URL_PATH +from django.test.utils import override_settings + +from awx.main.utils import get_awx_version +from django.utils import translation + + +class TestAnalyticsGenericView: + @pytest.mark.parametrize( + "existing_headers,expected_headers", + [ + ({}, {}), + ({'Hey': 'There'}, {}), # We don't forward just any headers + ({'Content-Type': 'text/html', 'Content-Length': '12'}, {'Content-Type': 'text/html', 'Content-Length': '12'}), + # Requests will auto-add the following headers (so we don't need to test them): 'Accept-Encoding', 'User-Agent', 'Accept' + ], + ) + def test__request_headers(self, existing_headers, expected_headers): + expected_headers['X-Rh-Analytics-Source'] = 'controller' + expected_headers['X-Rh-Analytics-Source-Version'] = get_awx_version() + expected_headers['Accept-Language'] = translation.get_language() + + request = requests.session() + request.headers.update(existing_headers) + assert set(expected_headers.items()).issubset(set(AnalyticsGenericView._request_headers(request).items())) + + @pytest.mark.parametrize( + "path,expected_path", + [ + ('A/B', f'{AUTOMATION_ANALYTICS_API_URL_PATH}/A/B'), + ('B', f'{AUTOMATION_ANALYTICS_API_URL_PATH}/B'), + ('/a/b/c/analytics/reports/my_slug', f'{AUTOMATION_ANALYTICS_API_URL_PATH}/reports/my_slug'), + ('/a/b/c/analytics/', f'{AUTOMATION_ANALYTICS_API_URL_PATH}/'), + ('/a/b/c/analytics', f'{AUTOMATION_ANALYTICS_API_URL_PATH}//a/b/c/analytics'), # Because there is no ending / on analytics we get a weird condition + ('/a/b/c/analytics/', f'{AUTOMATION_ANALYTICS_API_URL_PATH}/'), + ], + ) + @pytest.mark.django_db + def test__get_analytics_path(self, path, expected_path): + assert AnalyticsGenericView._get_analytics_path(path) == expected_path + + @pytest.mark.django_db + def test__get_analytics_url_no_url(self): + with override_settings(AUTOMATION_ANALYTICS_URL=None): + with pytest.raises(MissingSettings): + agw = AnalyticsGenericView() + agw._get_analytics_url('A') + + @pytest.mark.parametrize( + "request_path,ending_url", + [ + ('A', 'A'), + ('A/B', 'A/B'), + ('A/B/analytics/', ''), # we split on analytics but because there is nothing after + ('A/B/analytics/report', 'report'), + ('A/B/analytics/report/slug', 'report/slug'), + ], + ) + @pytest.mark.django_db + def test__get_analytics_url(self, request_path, ending_url): + base_url = 'http://testing' + with override_settings(AUTOMATION_ANALYTICS_URL=base_url): + agw = AnalyticsGenericView() + assert agw._get_analytics_url(request_path) == f'{base_url}{AUTOMATION_ANALYTICS_API_URL_PATH}/{ending_url}' + + @pytest.mark.parametrize( + "setting_name,setting_value,raises", + [ + ('INSIGHTS_TRACKING_STATE', None, True), + ('INSIGHTS_TRACKING_STATE', False, True), + ('INSIGHTS_TRACKING_STATE', True, False), + ('INSIGHTS_TRACKING_STATE', 'Steve', False), + ('INSIGHTS_TRACKING_STATE', 1, False), + ('INSIGHTS_TRACKING_STATE', '', True), + ], + ) + @pytest.mark.django_db + def test__get_setting(self, setting_name, setting_value, raises): + with override_settings(**{setting_name: setting_value}): + if raises: + with pytest.raises(MissingSettings): + AnalyticsGenericView._get_setting(setting_name, False, None) + else: + assert AnalyticsGenericView._get_setting(setting_name, False, None) == setting_value diff --git a/awx/main/tests/unit/api/test_views.py b/awx/main/tests/unit/api/test_views.py index 950ae57af4..edb60351d0 100644 --- a/awx/main/tests/unit/api/test_views.py +++ b/awx/main/tests/unit/api/test_views.py @@ -50,6 +50,7 @@ class TestApiRootView: 'activity_stream', 'workflow_job_templates', 'workflow_jobs', + 'analytics', ] view = ApiVersionRootView() ret = view.get(mocker.MagicMock()) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 82d0e7b343..7e1cba1c64 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -793,6 +793,7 @@ INSIGHTS_URL_BASE = "https://example.org" INSIGHTS_AGENT_MIME = 'application/example' # See https://github.com/ansible/awx-facts-playbooks INSIGHTS_SYSTEM_ID_FILE = '/etc/redhat-access-insights/machine-id' +INSIGHTS_CERT_PATH = "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" TOWER_SETTINGS_MANIFEST = {}