Analytics API: Paths, headers and Error handling

This commit is contained in:
Martin Slemr
2023-03-06 15:51:09 +01:00
committed by Hao Liu
parent 1191458d80
commit 0a40b758c3
5 changed files with 162 additions and 56 deletions

View File

@@ -8,7 +8,6 @@ import awx.api.views.analytics as analytics
urls = [ urls = [
re_path(r'^$', analytics.AnalyticsRootView.as_view(), name='analytics_root_view'), 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'^authorized/$', analytics.AnalyticsAuthorizedView.as_view(), name='analytics_authorized'),
re_path(r'^reports/$', analytics.AnalyticsReportsList.as_view(), name='analytics_reports_list'), re_path(r'^reports/$', analytics.AnalyticsReportsList.as_view(), name='analytics_reports_list'),
re_path(r'^report/(?P<slug>[\w-]+)/$', analytics.AnalyticsReportDetail.as_view(), name='analytics_report_detail'), re_path(r'^report/(?P<slug>[\w-]+)/$', analytics.AnalyticsReportDetail.as_view(), name='analytics_report_detail'),

View File

@@ -42,6 +42,7 @@ from awx.api.views.bulk import (
from awx.api.views.mesh_visualizer import MeshVisualizer from awx.api.views.mesh_visualizer import MeshVisualizer
from awx.api.views.metrics import MetricsView 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 .organization import urls as organization_urls
from .user import urls as user_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_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'^unified_jobs/$', UnifiedJobList.as_view(), name='unified_job_list'),
re_path(r'^activity_stream/', include(activity_stream_urls)), 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_approval_templates/', include(workflow_approval_template_urls)),
re_path(r'^workflow_approvals/', include(workflow_approval_urls)), re_path(r'^workflow_approvals/', include(workflow_approval_urls)),
re_path(r'^bulk/$', BulkView.as_view(), name='bulk'), re_path(r'^bulk/$', BulkView.as_view(), name='bulk'),

View File

@@ -1,24 +1,42 @@
import requests import requests
import logging import logging
import urllib.parse as urlparse
from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils import translation
from awx.api.generics import APIView, Response from awx.api.generics import APIView, Response
from awx.api.permissions import IsSystemAdminOrAuditor from awx.api.permissions import IsSystemAdminOrAuditor
from awx.api.versioning import reverse from awx.api.versioning import reverse
from awx.main.utils import get_awx_version
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework import status from rest_framework import status
from collections import OrderedDict from collections import OrderedDict
# docker-docker API request requires: AUTOMATION_ANALYTICS_API_URL_PATH = "/api/tower-analytics/v1"
# ``` AWX_ANALYTICS_API_PREFIX = 'analytics'
# 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"
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): class GetNotAllowedMixin(object):
@@ -37,31 +55,6 @@ class AnalyticsRootView(APIView):
return Response(data) 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): class AnalyticsGenericView(APIView):
""" """
Example: Example:
@@ -94,43 +87,159 @@ class AnalyticsGenericView(APIView):
permission_classes = (IsSystemAdminOrAuditor,) permission_classes = (IsSystemAdminOrAuditor,)
def _remove_api_path_prefix(self, request_path): @staticmethod
parts = request_path.split('analytics/') def _request_headers(request):
return parts[len(parts) - 1] 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): return headers
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)
def _forward_post(self, request, format=None): @staticmethod
analytics_path = self._remove_api_path_prefix(request.path) def _get_analytics_path(request_path):
response = requests.post(f'{AUTOMATION_ANALYTICS_API_URL}/{analytics_path}', params=request.query_params, headers=request.headers, json=request.data) parts = request_path.split(f'{AWX_ANALYTICS_API_PREFIX}/')
return Response(response.json(), status=response.status_code) 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): class AnalyticsGenericListView(AnalyticsGenericView):
def get(self, request, format=None): 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): def post(self, request, format=None):
return self._forward_post(request, format) return self._send_to_analytics(request, method="POST")
class AnalyticsGenericDetailView(AnalyticsGenericView): class AnalyticsGenericDetailView(AnalyticsGenericView):
def get(self, request, slug, format=None): 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): def post(self, request, slug, format=None):
return self._forward_post(request, format) return self._send_to_analytics(request, method="POST")
class AnalyticsAuthorizedView(AnalyticsGenericListView): class AnalyticsAuthorizedView(AnalyticsGenericListView):
name = _("Authorized") name = _("Authorized")
class AnalyticsReportsList(AnalyticsGenericListView): class AnalyticsReportsList(GetNotAllowedMixin, AnalyticsGenericListView):
name = _("Reports") name = _("Reports")
swagger_topic = "Automation Analytics" swagger_topic = "Automation Analytics"
@@ -156,8 +265,6 @@ class AnalyticsHostExplorerList(GetNotAllowedMixin, AnalyticsGenericListView):
class AnalyticsJobExplorerList(GetNotAllowedMixin, AnalyticsGenericListView): class AnalyticsJobExplorerList(GetNotAllowedMixin, AnalyticsGenericListView):
"""TODO: Allow Http GET?"""
name = _("Job Explorer") name = _("Job Explorer")

View File

@@ -359,9 +359,7 @@ def ship(path):
s.headers = get_awx_http_client_headers() s.headers = get_awx_http_client_headers()
s.headers.pop('Content-Type') s.headers.pop('Content-Type')
with set_environ(**settings.AWX_TASK_ENV): with set_environ(**settings.AWX_TASK_ENV):
response = s.post( response = s.post(url, files=files, verify=settings.INSIGHTS_CERT_PATH, auth=(rh_user, rh_password), headers=s.headers, timeout=(31, 31))
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)
)
# Accept 2XX status_codes # Accept 2XX status_codes
if response.status_code >= 300: if response.status_code >= 300:
logger.error('Upload failed with status {}, {}'.format(response.status_code, response.text)) logger.error('Upload failed with status {}, {}'.format(response.status_code, response.text))

View File

@@ -792,6 +792,7 @@ INSIGHTS_URL_BASE = "https://example.org"
INSIGHTS_AGENT_MIME = 'application/example' INSIGHTS_AGENT_MIME = 'application/example'
# See https://github.com/ansible/awx-facts-playbooks # See https://github.com/ansible/awx-facts-playbooks
INSIGHTS_SYSTEM_ID_FILE = '/etc/redhat-access-insights/machine-id' 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 = {} TOWER_SETTINGS_MANIFEST = {}