diff --git a/awx/api/urls/analytics.py b/awx/api/urls/analytics.py index 17078c32c3..c8601c881d 100644 --- a/awx/api/urls/analytics.py +++ b/awx/api/urls/analytics.py @@ -8,7 +8,6 @@ import awx.api.views.analytics as analytics urls = [ re_path(r'^$', analytics.AnalyticsRootView.as_view(), name='analytics_root_view'), - re_path(r'^test/$', analytics.AnalyticsTestList.as_view(), name='analytics_test_list'), 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'), diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index 673a1b11cd..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 @@ -147,7 +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(r'^analytics/', include(analytics_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 index ded72f7018..d59e202377 100644 --- a/awx/api/views/analytics.py +++ b/awx/api/views/analytics.py @@ -1,24 +1,42 @@ 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 -# docker-docker API request requires: -# ``` -# docker network connect koku_default tools_awx_1 -# ``` -AUTOMATION_ANALYTICS_API_URL = "http://automation-analytics-backend_fastapi_1:8080/api/tower-analytics/v1" -# AUTOMATION_ANALYTICS_API_URL = "http://localhost:8004/api/tower-analytics/v1" +AUTOMATION_ANALYTICS_API_URL_PATH = "/api/tower-analytics/v1" +AWX_ANALYTICS_API_PREFIX = 'analytics' -logger = logging.getLogger('awx.api.views') +CERT_PATH = "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" + +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): @@ -37,31 +55,6 @@ class AnalyticsRootView(APIView): return Response(data) -class AnalyticsTestList(APIView): - name = _("Testing") - permission_classes = (IsSystemAdminOrAuditor,) - swagger_topic = "AA" - - def get(self, request, format=None): - logger.info(f"TEST: {type(request.headers)}") - new_headers = request.headers.copy() - - data = { - 'get': { - 'method': request.method, - 'content-type': request.content_type, - 'data': request.data, - 'query_params': request.query_params, - 'headers': new_headers, - 'path': request.path, - } - } - return Response(data) - - def post(self, request, format=None): - return self.get(request, format) - - class AnalyticsGenericView(APIView): """ Example: @@ -94,43 +87,159 @@ class AnalyticsGenericView(APIView): permission_classes = (IsSystemAdminOrAuditor,) - def _remove_api_path_prefix(self, request_path): - parts = request_path.split('analytics/') - return parts[len(parts) - 1] + @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() - def _forward_get(self, request, format=None): - headers = {'Content-Type': 'application/json'} - analytics_path = self._remove_api_path_prefix(request.path) - response = requests.get(f'{AUTOMATION_ANALYTICS_API_URL}/{analytics_path}', params=request.query_params, headers=headers) - return Response(response.json(), status=response.status_code) + return headers - def _forward_post(self, request, format=None): - analytics_path = self._remove_api_path_prefix(request.path) - response = requests.post(f'{AUTOMATION_ANALYTICS_API_URL}/{analytics_path}', params=request.query_params, headers=request.headers, json=request.data) - return Response(response.json(), status=response.status_code) + @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 _check_upload_enabled(): + state = getattr(settings, 'INSIGHTS_TRACKING_STATE', False) + if not state: + raise MissingSettings(ERROR_UPLOAD_NOT_ENABLED) + return True + + @staticmethod + def _get_rh_user(): + user = getattr(settings, 'REDHAT_USERNAME', None) + if not user: + raise MissingSettings(ERROR_MISSING_USER) + return user + + @staticmethod + def _get_rh_password(): + password = getattr(settings, 'REDHAT_PASSWORD', None) + if not password: + raise MissingSettings(ERROR_MISSING_PASSWORD) + return password + + @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 _forward_response(response): + try: + content_type = response.headers.get('content-type', '') + if content_type.find('application/json') != -1: + return Response(response.json(), 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._check_upload_enabled() + url = self._get_analytics_url(request.path) + rh_user = self._get_rh_user() + rh_password = self._get_rh_password() + + if method not in ["GET", "POST"]: + 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._forward_get(request, format) + return self._send_to_analytics(request, method="GET") def post(self, request, format=None): - return self._forward_post(request, format) + return self._send_to_analytics(request, method="POST") class AnalyticsGenericDetailView(AnalyticsGenericView): def get(self, request, slug, format=None): - return self._forward_get(request, format) + return self._send_to_analytics(request, method="GET") def post(self, request, slug, format=None): - return self._forward_post(request, format) + return self._send_to_analytics(request, method="POST") class AnalyticsAuthorizedView(AnalyticsGenericListView): name = _("Authorized") -class AnalyticsReportsList(AnalyticsGenericListView): +class AnalyticsReportsList(GetNotAllowedMixin, AnalyticsGenericListView): name = _("Reports") swagger_topic = "Automation Analytics" @@ -156,8 +265,6 @@ class AnalyticsHostExplorerList(GetNotAllowedMixin, AnalyticsGenericListView): class AnalyticsJobExplorerList(GetNotAllowedMixin, AnalyticsGenericListView): - """TODO: Allow Http GET?""" - name = _("Job Explorer") diff --git a/awx/main/analytics/core.py b/awx/main/analytics/core.py index 77f6108205..90bfa198eb 100644 --- a/awx/main/analytics/core.py +++ b/awx/main/analytics/core.py @@ -359,9 +359,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/settings/defaults.py b/awx/settings/defaults.py index 593e6ae002..1f7a7c5346 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -792,6 +792,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 = {}