mirror of
https://github.com/ansible/awx.git
synced 2026-05-13 20:37:39 -02:30
Merge pull request #3484 from ansible/insights-integration
Insights integration
This commit is contained in:
@@ -31,7 +31,7 @@ from django.utils.translation import ugettext_lazy as _
|
|||||||
|
|
||||||
|
|
||||||
# Django REST Framework
|
# Django REST Framework
|
||||||
from rest_framework.exceptions import PermissionDenied, ParseError
|
from rest_framework.exceptions import APIException, PermissionDenied, ParseError, NotFound
|
||||||
from rest_framework.parsers import FormParser
|
from rest_framework.parsers import FormParser
|
||||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
from rest_framework.renderers import JSONRenderer, StaticHTMLRenderer
|
from rest_framework.renderers import JSONRenderer, StaticHTMLRenderer
|
||||||
@@ -1613,17 +1613,58 @@ class HostActivityStreamList(SubListAPIView):
|
|||||||
return qs.filter(Q(host=parent) | Q(inventory=parent.inventory))
|
return qs.filter(Q(host=parent) | Q(inventory=parent.inventory))
|
||||||
|
|
||||||
|
|
||||||
|
class BadGateway(APIException):
|
||||||
|
status_code = status.HTTP_502_BAD_GATEWAY
|
||||||
|
default_detail = ''
|
||||||
|
default_code = 'bad_gateway'
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayTimeout(APIException):
|
||||||
|
status_code = status.HTTP_504_GATEWAY_TIMEOUT
|
||||||
|
default_detail = ''
|
||||||
|
default_code = 'gateway_timeout'
|
||||||
|
|
||||||
|
|
||||||
class HostInsights(GenericAPIView):
|
class HostInsights(GenericAPIView):
|
||||||
|
|
||||||
model = models.Host
|
model = models.Host
|
||||||
serializer_class = serializers.EmptySerializer
|
serializer_class = serializers.EmptySerializer
|
||||||
|
|
||||||
def _extract_insights_creds(self, credential):
|
def _call_insights_api(self, url, session, headers):
|
||||||
return (credential.get_input('username', default=''), credential.get_input('password', default=''))
|
try:
|
||||||
|
res = session.get(url, headers=headers, timeout=120)
|
||||||
|
except requests.exceptions.SSLError:
|
||||||
|
raise BadGateway(_('SSLError while trying to connect to {}').format(url))
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
raise GatewayTimeout(_('Request to {} timed out.').format(url))
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise BadGateway(_('Unknown exception {} while trying to GET {}').format(e, url))
|
||||||
|
|
||||||
def _get_insights(self, url, username, password):
|
if res.status_code == 401:
|
||||||
|
raise BadGateway(
|
||||||
|
_('Unauthorized access. Please check your Insights Credential username and password.'))
|
||||||
|
elif res.status_code != 200:
|
||||||
|
raise BadGateway(
|
||||||
|
_(
|
||||||
|
'Failed to access the Insights API at URL {}.'
|
||||||
|
' Server responded with {} status code and message {}'
|
||||||
|
).format(url, res.status_code, res.content)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return res.json()
|
||||||
|
except ValueError:
|
||||||
|
raise BadGateway(
|
||||||
|
_('Expected JSON response from Insights at URL {}'
|
||||||
|
' but instead got {}').format(url, res.content))
|
||||||
|
|
||||||
|
def _get_session(self, username, password):
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
session.auth = requests.auth.HTTPBasicAuth(username, password)
|
session.auth = requests.auth.HTTPBasicAuth(username, password)
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
def _get_headers(self):
|
||||||
license = get_license(show_key=False).get('license_type', 'UNLICENSED')
|
license = get_license(show_key=False).get('license_type', 'UNLICENSED')
|
||||||
headers = {
|
headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -1633,47 +1674,84 @@ class HostInsights(GenericAPIView):
|
|||||||
license
|
license
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return session.get(url, headers=headers, timeout=120)
|
|
||||||
|
|
||||||
def get_insights(self, url, username, password):
|
return headers
|
||||||
|
|
||||||
|
def _get_platform_info(self, host, session, headers):
|
||||||
|
url = '{}/api/inventory/v1/hosts?insights_id={}'.format(
|
||||||
|
settings.INSIGHTS_URL_BASE, host.insights_system_id)
|
||||||
|
res = self._call_insights_api(url, session, headers)
|
||||||
try:
|
try:
|
||||||
res = self._get_insights(url, username, password)
|
res['results'][0]['id']
|
||||||
except requests.exceptions.SSLError:
|
except (IndexError, KeyError):
|
||||||
return (dict(error=_('SSLError while trying to connect to {}').format(url)), status.HTTP_502_BAD_GATEWAY)
|
raise NotFound(
|
||||||
except requests.exceptions.Timeout:
|
_('Could not translate Insights system ID {}'
|
||||||
return (dict(error=_('Request to {} timed out.').format(url)), status.HTTP_504_GATEWAY_TIMEOUT)
|
' into an Insights platform ID.').format(host.insights_system_id))
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
return (dict(error=_('Unknown exception {} while trying to GET {}').format(e, url)), status.HTTP_502_BAD_GATEWAY)
|
|
||||||
|
|
||||||
if res.status_code == 401:
|
return res['results'][0]
|
||||||
return (dict(error=_('Unauthorized access. Please check your Insights Credential username and password.')), status.HTTP_502_BAD_GATEWAY)
|
|
||||||
elif res.status_code != 200:
|
|
||||||
return (dict(error=_(
|
|
||||||
'Failed to gather reports and maintenance plans from Insights API at URL {}. Server responded with {} status code and message {}'
|
|
||||||
).format(url, res.status_code, res.content)), status.HTTP_502_BAD_GATEWAY)
|
|
||||||
|
|
||||||
try:
|
def _get_reports(self, platform_id, session, headers):
|
||||||
filtered_insights_content = filter_insights_api_response(res.json())
|
url = '{}/api/insights/v1/system/{}/reports/'.format(
|
||||||
return (dict(insights_content=filtered_insights_content), status.HTTP_200_OK)
|
settings.INSIGHTS_URL_BASE, platform_id)
|
||||||
except ValueError:
|
|
||||||
return (dict(error=_('Expected JSON response from Insights but instead got {}').format(res.content)), status.HTTP_502_BAD_GATEWAY)
|
return self._call_insights_api(url, session, headers)
|
||||||
|
|
||||||
|
def _get_remediations(self, platform_id, session, headers):
|
||||||
|
url = '{}/api/remediations/v1/remediations?system={}'.format(
|
||||||
|
settings.INSIGHTS_URL_BASE, platform_id)
|
||||||
|
|
||||||
|
remediations = []
|
||||||
|
|
||||||
|
# Iterate over all of the pages of content.
|
||||||
|
while url:
|
||||||
|
data = self._call_insights_api(url, session, headers)
|
||||||
|
remediations.extend(data['data'])
|
||||||
|
|
||||||
|
url = data['links']['next'] # Will be `None` if this is the last page.
|
||||||
|
|
||||||
|
return remediations
|
||||||
|
|
||||||
|
def _get_insights(self, host, session, headers):
|
||||||
|
platform_info = self._get_platform_info(host, session, headers)
|
||||||
|
platform_id = platform_info['id']
|
||||||
|
reports = self._get_reports(platform_id, session, headers)
|
||||||
|
remediations = self._get_remediations(platform_id, session, headers)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'insights_content': filter_insights_api_response(platform_info, reports, remediations)
|
||||||
|
}
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
host = self.get_object()
|
host = self.get_object()
|
||||||
cred = None
|
cred = None
|
||||||
|
|
||||||
if host.insights_system_id is None:
|
if host.insights_system_id is None:
|
||||||
return Response(dict(error=_('This host is not recognized as an Insights host.')), status=status.HTTP_404_NOT_FOUND)
|
return Response(
|
||||||
|
dict(error=_('This host is not recognized as an Insights host.')),
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
if host.inventory and host.inventory.insights_credential:
|
if host.inventory and host.inventory.insights_credential:
|
||||||
cred = host.inventory.insights_credential
|
cred = host.inventory.insights_credential
|
||||||
else:
|
else:
|
||||||
return Response(dict(error=_('The Insights Credential for "{}" was not found.').format(host.inventory.name)), status=status.HTTP_404_NOT_FOUND)
|
return Response(
|
||||||
|
dict(error=_('The Insights Credential for "{}" was not found.').format(host.inventory.name)),
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
url = settings.INSIGHTS_URL_BASE + '/r/insights/v3/systems/{}/reports/'.format(host.insights_system_id)
|
username = cred.get_input('username', default='')
|
||||||
(username, password) = self._extract_insights_creds(cred)
|
password = cred.get_input('password', default='')
|
||||||
(msg, err_code) = self.get_insights(url, username, password)
|
session = self._get_session(username, password)
|
||||||
return Response(msg, status=err_code)
|
headers = self._get_headers()
|
||||||
|
|
||||||
|
data = self._get_insights(host, session, headers)
|
||||||
|
return Response(data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def handle_exception(self, exc):
|
||||||
|
# Continue supporting the slightly different way we have handled error responses on this view.
|
||||||
|
response = super().handle_exception(exc)
|
||||||
|
response.data['error'] = response.data.pop('detail')
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
class GroupList(ListCreateAPIView):
|
class GroupList(ListCreateAPIView):
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -4,6 +4,11 @@ import os
|
|||||||
|
|
||||||
dir_path = os.path.dirname(os.path.realpath(__file__))
|
dir_path = os.path.dirname(os.path.realpath(__file__))
|
||||||
|
|
||||||
with open(os.path.join(dir_path, 'insights.json')) as data_file:
|
with open(os.path.join(dir_path, 'insights_hosts.json')) as data_file:
|
||||||
TEST_INSIGHTS_PLANS = json.loads(data_file.read())
|
TEST_INSIGHTS_HOSTS = json.load(data_file)
|
||||||
|
|
||||||
|
with open(os.path.join(dir_path, 'insights.json')) as data_file:
|
||||||
|
TEST_INSIGHTS_PLANS = json.load(data_file)
|
||||||
|
|
||||||
|
with open(os.path.join(dir_path, 'insights_remediations.json')) as data_file:
|
||||||
|
TEST_INSIGHTS_REMEDIATIONS = json.load(data_file)['data']
|
||||||
|
|||||||
13
awx/main/tests/data/insights_hosts.json
Normal file
13
awx/main/tests/data/insights_hosts.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"total": 1,
|
||||||
|
"count": 1,
|
||||||
|
"page": 1,
|
||||||
|
"per_page": 50,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"insights_id": "22222222-2222-2222-2222-222222222222",
|
||||||
|
"updated": "2019-03-19T21:59:09.213151-04:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
33
awx/main/tests/data/insights_remediations.json
Normal file
33
awx/main/tests/data/insights_remediations.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "9197ba55-0abc-4028-9bbe-269e530f8bd5",
|
||||||
|
"name": "Fix Critical CVEs",
|
||||||
|
"created_by": {
|
||||||
|
"username": "jharting@redhat.com",
|
||||||
|
"first_name": "Jozef",
|
||||||
|
"last_name": "Hartinger"
|
||||||
|
},
|
||||||
|
"created_at": "2018-12-05T08:19:36.641Z",
|
||||||
|
"updated_by": {
|
||||||
|
"username": "jharting@redhat.com",
|
||||||
|
"first_name": "Jozef",
|
||||||
|
"last_name": "Hartinger"
|
||||||
|
},
|
||||||
|
"updated_at": "2018-12-05T08:19:36.641Z",
|
||||||
|
"issue_count": 0,
|
||||||
|
"system_count": 0,
|
||||||
|
"needs_reboot": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"count": 0,
|
||||||
|
"total": 0
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"first": null,
|
||||||
|
"last": null,
|
||||||
|
"next": null,
|
||||||
|
"previous": null
|
||||||
|
}
|
||||||
|
}
|
||||||
135
awx/main/tests/functional/api/test_host_insights.py
Normal file
135
awx/main/tests/functional/api/test_host_insights.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from awx.api.versioning import reverse
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestHostInsights:
|
||||||
|
def test_insights_bad_host(self, get, hosts, user, mocker):
|
||||||
|
mocker.patch.object(requests.Session, 'get')
|
||||||
|
|
||||||
|
host = hosts(host_count=1)[0]
|
||||||
|
|
||||||
|
url = reverse('api:host_insights', kwargs={'pk': host.pk})
|
||||||
|
response = get(url, user('admin', True))
|
||||||
|
|
||||||
|
assert response.data['error'] == 'This host is not recognized as an Insights host.'
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_insights_host_missing_from_insights(self, get, hosts, insights_credential, user, mocker):
|
||||||
|
class Response:
|
||||||
|
status_code = 200
|
||||||
|
content = "{'results': []}"
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {'results': []}
|
||||||
|
|
||||||
|
mocker.patch.object(requests.Session, 'get', return_value=Response())
|
||||||
|
|
||||||
|
host = hosts(host_count=1)[0]
|
||||||
|
host.insights_system_id = '123e4567-e89b-12d3-a456-426655440000'
|
||||||
|
host.inventory.insights_credential = insights_credential
|
||||||
|
host.inventory.save()
|
||||||
|
host.save()
|
||||||
|
|
||||||
|
url = reverse('api:host_insights', kwargs={'pk': host.pk})
|
||||||
|
response = get(url, user('admin', True))
|
||||||
|
|
||||||
|
assert response.data['error'] == (
|
||||||
|
'Could not translate Insights system ID 123e4567-e89b-12d3-a456-426655440000'
|
||||||
|
' into an Insights platform ID.')
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_insights_no_credential(self, get, hosts, user, mocker):
|
||||||
|
mocker.patch.object(requests.Session, 'get')
|
||||||
|
|
||||||
|
host = hosts(host_count=1)[0]
|
||||||
|
host.insights_system_id = '123e4567-e89b-12d3-a456-426655440000'
|
||||||
|
host.save()
|
||||||
|
|
||||||
|
url = reverse('api:host_insights', kwargs={'pk': host.pk})
|
||||||
|
response = get(url, user('admin', True))
|
||||||
|
|
||||||
|
assert response.data['error'] == 'The Insights Credential for "test-inv" was not found.'
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status_code, exception, error, message", [
|
||||||
|
(502, requests.exceptions.SSLError, 'SSLError while trying to connect to https://myexample.com/whocares/me/', None,),
|
||||||
|
(504, requests.exceptions.Timeout, 'Request to https://myexample.com/whocares/me/ timed out.', None,),
|
||||||
|
(502, requests.exceptions.RequestException, 'booo!', 'Unknown exception booo! while trying to GET https://myexample.com/whocares/me/'),
|
||||||
|
])
|
||||||
|
def test_insights_exception(self, get, hosts, insights_credential, user, mocker, status_code, exception, error, message):
|
||||||
|
mocker.patch.object(requests.Session, 'get', side_effect=exception(error))
|
||||||
|
|
||||||
|
host = hosts(host_count=1)[0]
|
||||||
|
host.insights_system_id = '123e4567-e89b-12d3-a456-426655440000'
|
||||||
|
host.inventory.insights_credential = insights_credential
|
||||||
|
host.inventory.save()
|
||||||
|
host.save()
|
||||||
|
|
||||||
|
url = reverse('api:host_insights', kwargs={'pk': host.pk})
|
||||||
|
response = get(url, user('admin', True))
|
||||||
|
|
||||||
|
assert response.data['error'] == message or error
|
||||||
|
assert response.status_code == status_code
|
||||||
|
|
||||||
|
def test_insights_unauthorized(self, get, hosts, insights_credential, user, mocker):
|
||||||
|
Response = namedtuple('Response', 'status_code content')
|
||||||
|
mocker.patch.object(requests.Session, 'get', return_value=Response(401, 'mock 401 err msg'))
|
||||||
|
|
||||||
|
host = hosts(host_count=1)[0]
|
||||||
|
host.insights_system_id = '123e4567-e89b-12d3-a456-426655440000'
|
||||||
|
host.inventory.insights_credential = insights_credential
|
||||||
|
host.inventory.save()
|
||||||
|
host.save()
|
||||||
|
|
||||||
|
url = reverse('api:host_insights', kwargs={'pk': host.pk})
|
||||||
|
response = get(url, user('admin', True))
|
||||||
|
|
||||||
|
assert response.data['error'] == (
|
||||||
|
"Unauthorized access. Please check your Insights Credential username and password.")
|
||||||
|
assert response.status_code == 502
|
||||||
|
|
||||||
|
def test_insights_bad_status(self, get, hosts, insights_credential, user, mocker):
|
||||||
|
Response = namedtuple('Response', 'status_code content')
|
||||||
|
mocker.patch.object(requests.Session, 'get', return_value=Response(500, 'mock 500 err msg'))
|
||||||
|
|
||||||
|
host = hosts(host_count=1)[0]
|
||||||
|
host.insights_system_id = '123e4567-e89b-12d3-a456-426655440000'
|
||||||
|
host.inventory.insights_credential = insights_credential
|
||||||
|
host.inventory.save()
|
||||||
|
host.save()
|
||||||
|
|
||||||
|
url = reverse('api:host_insights', kwargs={'pk': host.pk})
|
||||||
|
response = get(url, user('admin', True))
|
||||||
|
|
||||||
|
assert response.data['error'].startswith("Failed to access the Insights API at URL")
|
||||||
|
assert "Server responded with 500 status code and message mock 500 err msg" in response.data['error']
|
||||||
|
assert response.status_code == 502
|
||||||
|
|
||||||
|
def test_insights_bad_json(self, get, hosts, insights_credential, user, mocker):
|
||||||
|
class Response:
|
||||||
|
status_code = 200
|
||||||
|
content = 'booo!'
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
raise ValueError("we do not care what this is")
|
||||||
|
|
||||||
|
mocker.patch.object(requests.Session, 'get', return_value=Response())
|
||||||
|
|
||||||
|
host = hosts(host_count=1)[0]
|
||||||
|
host.insights_system_id = '123e4567-e89b-12d3-a456-426655440000'
|
||||||
|
host.inventory.insights_credential = insights_credential
|
||||||
|
host.inventory.save()
|
||||||
|
host.save()
|
||||||
|
|
||||||
|
url = reverse('api:host_insights', kwargs={'pk': host.pk})
|
||||||
|
response = get(url, user('admin', True))
|
||||||
|
|
||||||
|
assert response.data['error'].startswith("Expected JSON response from Insights at URL")
|
||||||
|
assert 'insights_id=123e4567-e89b-12d3-a456-426655440000' in response.data['error']
|
||||||
|
assert response.data['error'].endswith("but instead got booo!")
|
||||||
|
assert response.status_code == 502
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import re
|
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
@@ -11,13 +9,9 @@ from awx.api.views import (
|
|||||||
ApiVersionRootView,
|
ApiVersionRootView,
|
||||||
JobTemplateLabelList,
|
JobTemplateLabelList,
|
||||||
InventoryInventorySourcesUpdate,
|
InventoryInventorySourcesUpdate,
|
||||||
HostInsights,
|
|
||||||
JobTemplateSurveySpec
|
JobTemplateSurveySpec
|
||||||
)
|
)
|
||||||
|
|
||||||
from awx.main.models import (
|
|
||||||
Host,
|
|
||||||
)
|
|
||||||
from awx.main.views import handle_error
|
from awx.main.views import handle_error
|
||||||
|
|
||||||
from rest_framework.test import APIRequestFactory
|
from rest_framework.test import APIRequestFactory
|
||||||
@@ -122,103 +116,6 @@ class TestInventoryInventorySourcesUpdate:
|
|||||||
assert response.data == expected
|
assert response.data == expected
|
||||||
|
|
||||||
|
|
||||||
class TestHostInsights():
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def patch_parent(self, mocker):
|
|
||||||
mocker.patch('awx.api.generics.GenericAPIView')
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("status_code, exception, error, message", [
|
|
||||||
(502, requests.exceptions.SSLError, 'SSLError while trying to connect to https://myexample.com/whocares/me/', None,),
|
|
||||||
(504, requests.exceptions.Timeout, 'Request to https://myexample.com/whocares/me/ timed out.', None,),
|
|
||||||
(502, requests.exceptions.RequestException, 'booo!', 'Unknown exception booo! while trying to GET https://myexample.com/whocares/me/'),
|
|
||||||
])
|
|
||||||
def test_get_insights_request_exception(self, patch_parent, mocker, status_code, exception, error, message):
|
|
||||||
view = HostInsights()
|
|
||||||
mocker.patch.object(view, '_get_insights', side_effect=exception(error))
|
|
||||||
|
|
||||||
(msg, code) = view.get_insights('https://myexample.com/whocares/me/', 'ignore', 'ignore')
|
|
||||||
assert code == status_code
|
|
||||||
assert msg['error'] == message or error
|
|
||||||
|
|
||||||
def test_get_insights_non_200(self, patch_parent, mocker):
|
|
||||||
view = HostInsights()
|
|
||||||
Response = namedtuple('Response', 'status_code content')
|
|
||||||
mocker.patch.object(view, '_get_insights', return_value=Response(500, 'mock 500 err msg'))
|
|
||||||
|
|
||||||
(msg, code) = view.get_insights('https://myexample.com/whocares/me/', 'ignore', 'ignore')
|
|
||||||
assert msg['error'] == (
|
|
||||||
'Failed to gather reports and maintenance plans from Insights API at URL'
|
|
||||||
' https://myexample.com/whocares/me/. Server responded with 500 status code '
|
|
||||||
'and message mock 500 err msg')
|
|
||||||
|
|
||||||
def test_get_insights_401(self, patch_parent, mocker):
|
|
||||||
view = HostInsights()
|
|
||||||
Response = namedtuple('Response', 'status_code content')
|
|
||||||
mocker.patch.object(view, '_get_insights', return_value=Response(401, ''))
|
|
||||||
|
|
||||||
(msg, code) = view.get_insights('https://myexample.com/whocares/me/', 'ignore', 'ignore')
|
|
||||||
assert msg['error'] == 'Unauthorized access. Please check your Insights Credential username and password.'
|
|
||||||
|
|
||||||
def test_get_insights_malformed_json_content(self, patch_parent, mocker):
|
|
||||||
view = HostInsights()
|
|
||||||
|
|
||||||
class Response():
|
|
||||||
status_code = 200
|
|
||||||
content = 'booo!'
|
|
||||||
|
|
||||||
def json(self):
|
|
||||||
raise ValueError('we do not care what this is')
|
|
||||||
|
|
||||||
mocker.patch.object(view, '_get_insights', return_value=Response())
|
|
||||||
|
|
||||||
(msg, code) = view.get_insights('https://myexample.com/whocares/me/', 'ignore', 'ignore')
|
|
||||||
assert msg['error'] == 'Expected JSON response from Insights but instead got booo!'
|
|
||||||
assert code == 502
|
|
||||||
|
|
||||||
#def test_get_not_insights_host(self, patch_parent, mocker, mock_response_new):
|
|
||||||
#def test_get_not_insights_host(self, patch_parent, mocker):
|
|
||||||
def test_get_not_insights_host(self, mocker):
|
|
||||||
|
|
||||||
view = HostInsights()
|
|
||||||
|
|
||||||
host = Host()
|
|
||||||
host.insights_system_id = None
|
|
||||||
|
|
||||||
mocker.patch.object(view, 'get_object', return_value=host)
|
|
||||||
|
|
||||||
resp = view.get(None)
|
|
||||||
|
|
||||||
assert resp.data['error'] == 'This host is not recognized as an Insights host.'
|
|
||||||
assert resp.status_code == 404
|
|
||||||
|
|
||||||
def test_get_no_credential(self, patch_parent, mocker):
|
|
||||||
view = HostInsights()
|
|
||||||
|
|
||||||
class MockInventory():
|
|
||||||
insights_credential = None
|
|
||||||
name = 'inventory_name_here'
|
|
||||||
|
|
||||||
class MockHost():
|
|
||||||
insights_system_id = 'insights_system_id_value'
|
|
||||||
inventory = MockInventory()
|
|
||||||
|
|
||||||
mocker.patch.object(view, 'get_object', return_value=MockHost())
|
|
||||||
|
|
||||||
resp = view.get(None)
|
|
||||||
|
|
||||||
assert resp.data['error'] == 'The Insights Credential for "inventory_name_here" was not found.'
|
|
||||||
assert resp.status_code == 404
|
|
||||||
|
|
||||||
def test_get_insights_user_agent(self, patch_parent, mocker):
|
|
||||||
with mock.patch.object(requests.Session, 'get') as get:
|
|
||||||
HostInsights()._get_insights('https://example.org', 'joe', 'example')
|
|
||||||
assert get.call_count == 1
|
|
||||||
args, kwargs = get.call_args_list[0]
|
|
||||||
assert args == ('https://example.org',)
|
|
||||||
assert re.match(r'AWX [^\s]+ \(open\)', kwargs['headers']['User-Agent'])
|
|
||||||
|
|
||||||
|
|
||||||
class TestSurveySpecValidation:
|
class TestSurveySpecValidation:
|
||||||
|
|
||||||
def test_create_text_encrypted(self):
|
def test_create_text_encrypted(self):
|
||||||
|
|||||||
@@ -3,22 +3,25 @@
|
|||||||
|
|
||||||
|
|
||||||
from awx.main.utils.insights import filter_insights_api_response
|
from awx.main.utils.insights import filter_insights_api_response
|
||||||
from awx.main.tests.data.insights import TEST_INSIGHTS_PLANS
|
from awx.main.tests.data.insights import TEST_INSIGHTS_HOSTS, TEST_INSIGHTS_PLANS, TEST_INSIGHTS_REMEDIATIONS
|
||||||
|
|
||||||
|
|
||||||
def test_filter_insights_api_response():
|
def test_filter_insights_api_response():
|
||||||
actual = filter_insights_api_response(TEST_INSIGHTS_PLANS)
|
actual = filter_insights_api_response(
|
||||||
|
TEST_INSIGHTS_HOSTS['results'][0], TEST_INSIGHTS_PLANS, TEST_INSIGHTS_REMEDIATIONS)
|
||||||
|
|
||||||
assert actual['last_check_in'] == '2017-07-21T07:07:29.000Z'
|
assert actual['last_check_in'] == '2019-03-19T21:59:09.213151-04:00'
|
||||||
assert len(actual['reports']) == 9
|
assert len(actual['reports']) == 5
|
||||||
assert actual['reports'][0]['maintenance_actions'][0]['maintenance_plan']['name'] == "RHEL Demo Infrastructure"
|
assert len(actual['reports'][0]['maintenance_actions']) == 1
|
||||||
assert actual['reports'][0]['maintenance_actions'][0]['maintenance_plan']['maintenance_id'] == 29315
|
assert actual['reports'][0]['maintenance_actions'][0]['name'] == "Fix Critical CVEs"
|
||||||
assert actual['reports'][0]['rule']['severity'] == 'ERROR'
|
rule = actual['reports'][0]['rule']
|
||||||
assert actual['reports'][0]['rule']['description'] == 'Remote code execution vulnerability in libresolv via crafted DNS response (CVE-2015-7547)'
|
|
||||||
assert actual['reports'][0]['rule']['category'] == 'Security'
|
|
||||||
assert actual['reports'][0]['rule']['summary'] == ("A critical security flaw in the `glibc` library was found. "
|
|
||||||
"It allows an attacker to crash an application built against "
|
|
||||||
"that library or, potentially, execute arbitrary code with "
|
|
||||||
"privileges of the user running the application.")
|
|
||||||
assert actual['reports'][0]['rule']['ansible_fix'] is False
|
|
||||||
|
|
||||||
|
assert rule['severity'] == 'WARN'
|
||||||
|
assert rule['description'] == (
|
||||||
|
"Kernel vulnerable to side-channel attacks in modern microprocessors (CVE-2017-5715/Spectre)")
|
||||||
|
assert rule['category'] == 'Security'
|
||||||
|
assert rule['summary'] == (
|
||||||
|
"A vulnerability was discovered in modern microprocessors supported by the kernel,"
|
||||||
|
" whereby an unprivileged attacker can use this flaw to bypass restrictions to gain read"
|
||||||
|
" access to privileged memory.\nThe issue was reported as [CVE-2017-5715 / Spectre]"
|
||||||
|
"(https://access.redhat.com/security/cve/CVE-2017-5715).\n")
|
||||||
|
|||||||
@@ -2,42 +2,46 @@
|
|||||||
# All Rights Reserved.
|
# All Rights Reserved.
|
||||||
|
|
||||||
|
|
||||||
def filter_insights_api_response(json):
|
# Old Insights API -> New API
|
||||||
new_json = {}
|
#
|
||||||
'''
|
# last_check_in is missing entirely, is now provided by a different endpoint
|
||||||
'last_check_in',
|
# reports[] -> []
|
||||||
'reports.[].rule.severity',
|
# reports[].rule.{description,summary} -> [].rule.{description,summary}
|
||||||
'reports.[].rule.description',
|
# reports[].rule.category -> [].rule.category.name
|
||||||
'reports.[].rule.category',
|
# reports[].rule.severity (str) -> [].rule.total_risk (int)
|
||||||
'reports.[].rule.summary',
|
|
||||||
'reports.[].rule.ansible_fix',
|
|
||||||
'reports.[].rule.ansible',
|
|
||||||
'reports.[].maintenance_actions.[].maintenance_plan.name',
|
|
||||||
'reports.[].maintenance_actions.[].maintenance_plan.maintenance_id',
|
|
||||||
'''
|
|
||||||
|
|
||||||
if 'last_check_in' in json:
|
# reports[].rule.{ansible,ansible_fix} appears to be unused
|
||||||
new_json['last_check_in'] = json['last_check_in']
|
# reports[].maintenance_actions[] missing entirely, is now provided
|
||||||
if 'reports' in json:
|
# by a different Insights endpoint
|
||||||
new_json['reports'] = []
|
|
||||||
for rep in json['reports']:
|
|
||||||
new_report = {
|
def filter_insights_api_response(platform_info, reports, remediations):
|
||||||
'rule': {},
|
severity_mapping = {
|
||||||
'maintenance_actions': []
|
1: 'INFO',
|
||||||
}
|
2: 'WARN',
|
||||||
if 'rule' in rep:
|
3: 'ERROR',
|
||||||
for k in ['severity', 'description', 'category', 'summary', 'ansible_fix', 'ansible',]:
|
4: 'CRITICAL'
|
||||||
if k in rep['rule']:
|
}
|
||||||
new_report['rule'][k] = rep['rule'][k]
|
|
||||||
|
new_json = {
|
||||||
|
'platform_id': platform_info['id'],
|
||||||
|
'last_check_in': platform_info.get('updated'),
|
||||||
|
'reports': [],
|
||||||
|
}
|
||||||
|
for rep in reports:
|
||||||
|
new_report = {
|
||||||
|
'rule': {},
|
||||||
|
'maintenance_actions': remediations
|
||||||
|
}
|
||||||
|
rule = rep.get('rule') or {}
|
||||||
|
for k in ['description', 'summary']:
|
||||||
|
if k in rule:
|
||||||
|
new_report['rule'][k] = rule[k]
|
||||||
|
if 'category' in rule:
|
||||||
|
new_report['rule']['category'] = rule['category']['name']
|
||||||
|
if rule.get('total_risk') in severity_mapping:
|
||||||
|
new_report['rule']['severity'] = severity_mapping[rule['total_risk']]
|
||||||
|
|
||||||
|
new_json['reports'].append(new_report)
|
||||||
|
|
||||||
for action in rep.get('maintenance_actions', []):
|
|
||||||
new_action = {'maintenance_plan': {}}
|
|
||||||
if 'maintenance_plan' in action:
|
|
||||||
for k in ['name', 'maintenance_id']:
|
|
||||||
if k in action['maintenance_plan']:
|
|
||||||
new_action['maintenance_plan'][k] = action['maintenance_plan'][k]
|
|
||||||
new_report['maintenance_actions'].append(new_action)
|
|
||||||
|
|
||||||
new_json['reports'].append(new_report)
|
|
||||||
return new_json
|
return new_json
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from __future__ import (absolute_import, division, print_function)
|
|||||||
__metaclass__ = type
|
__metaclass__ = type
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from ansible.plugins.action import ActionBase
|
from ansible.plugins.action import ActionBase
|
||||||
@@ -9,8 +11,11 @@ from ansible.plugins.action import ActionBase
|
|||||||
|
|
||||||
class ActionModule(ActionBase):
|
class ActionModule(ActionBase):
|
||||||
|
|
||||||
def save_playbook(self, proj_path, plan, content):
|
def save_playbook(self, proj_path, remediation, content):
|
||||||
fname = '{}-{}.yml'.format(plan.get('name', None) or 'insights-plan', plan['maintenance_id'])
|
name = remediation.get('name', None) or 'insights-remediation'
|
||||||
|
name = re.sub(r'[^\w\s-]', '', name).strip().lower()
|
||||||
|
name = re.sub(r'[-\s]+', '-', name)
|
||||||
|
fname = '{}-{}.yml'.format(name, remediation['id'])
|
||||||
file_path = os.path.join(proj_path, fname)
|
file_path = os.path.join(proj_path, fname)
|
||||||
with open(file_path, 'wb') as f:
|
with open(file_path, 'wb') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
@@ -18,9 +23,8 @@ class ActionModule(ActionBase):
|
|||||||
def is_stale(self, proj_path, etag):
|
def is_stale(self, proj_path, etag):
|
||||||
file_path = os.path.join(proj_path, '.version')
|
file_path = os.path.join(proj_path, '.version')
|
||||||
try:
|
try:
|
||||||
f = open(file_path, 'r')
|
with open(file_path, 'r') as f:
|
||||||
version = f.read()
|
version = f.read()
|
||||||
f.close()
|
|
||||||
return version != etag
|
return version != etag
|
||||||
except IOError:
|
except IOError:
|
||||||
return True
|
return True
|
||||||
@@ -31,7 +35,6 @@ class ActionModule(ActionBase):
|
|||||||
f.write(etag)
|
f.write(etag)
|
||||||
|
|
||||||
def run(self, tmp=None, task_vars=None):
|
def run(self, tmp=None, task_vars=None):
|
||||||
|
|
||||||
self._supports_check_mode = False
|
self._supports_check_mode = False
|
||||||
|
|
||||||
result = super(ActionModule, self).run(tmp, task_vars)
|
result = super(ActionModule, self).run(tmp, task_vars)
|
||||||
@@ -53,35 +56,10 @@ class ActionModule(ActionBase):
|
|||||||
license
|
license
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
url = '/api/remediations/v1/remediations'
|
||||||
|
while url:
|
||||||
|
res = session.get('{}{}'.format(insights_url, url), headers=headers, timeout=120)
|
||||||
|
|
||||||
|
|
||||||
url = '{}/r/insights/v3/maintenance?ansible=true'.format(insights_url)
|
|
||||||
|
|
||||||
res = session.get(url, headers=headers, timeout=120)
|
|
||||||
|
|
||||||
if res.status_code != 200:
|
|
||||||
result['failed'] = True
|
|
||||||
result['msg'] = (
|
|
||||||
'Expected {} to return a status code of 200 but returned status '
|
|
||||||
'code "{}" instead with content "{}".'.format(url, res.status_code, res.content)
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
if 'ETag' in res.headers:
|
|
||||||
version = res.headers['ETag']
|
|
||||||
if version.startswith('"') and version.endswith('"'):
|
|
||||||
version = version[1:-1]
|
|
||||||
else:
|
|
||||||
version = "ETAG_NOT_FOUND"
|
|
||||||
|
|
||||||
if not self.is_stale(proj_path, version):
|
|
||||||
result['changed'] = False
|
|
||||||
result['version'] = version
|
|
||||||
return result
|
|
||||||
|
|
||||||
for item in res.json():
|
|
||||||
url = '{}/r/insights/v3/maintenance/{}/playbook'.format(insights_url, item['maintenance_id'])
|
|
||||||
res = session.get(url, timeout=120)
|
|
||||||
if res.status_code != 200:
|
if res.status_code != 200:
|
||||||
result['failed'] = True
|
result['failed'] = True
|
||||||
result['msg'] = (
|
result['msg'] = (
|
||||||
@@ -89,7 +67,37 @@ class ActionModule(ActionBase):
|
|||||||
'code "{}" instead with content "{}".'.format(url, res.status_code, res.content)
|
'code "{}" instead with content "{}".'.format(url, res.status_code, res.content)
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
self.save_playbook(proj_path, item, res.content)
|
|
||||||
|
# FIXME: ETags are (maybe?) not yet supported in the new
|
||||||
|
# API, and even if they are we'll need to put some thought
|
||||||
|
# into how to deal with them in combination with pagination.
|
||||||
|
if 'ETag' in res.headers:
|
||||||
|
version = res.headers['ETag']
|
||||||
|
if version.startswith('"') and version.endswith('"'):
|
||||||
|
version = version[1:-1]
|
||||||
|
else:
|
||||||
|
version = "ETAG_NOT_FOUND"
|
||||||
|
|
||||||
|
if not self.is_stale(proj_path, version):
|
||||||
|
result['changed'] = False
|
||||||
|
result['version'] = version
|
||||||
|
return result
|
||||||
|
|
||||||
|
url = res.json()['links']['next'] # will be None if we're on the last page
|
||||||
|
|
||||||
|
for item in res.json()['data']:
|
||||||
|
playbook_url = '{}/api/remediations/v1/remediations/{}/playbook'.format(
|
||||||
|
insights_url, item['id'])
|
||||||
|
res = session.get(playbook_url, timeout=120)
|
||||||
|
if res.status_code != 200:
|
||||||
|
result['failed'] = True
|
||||||
|
result['msg'] = (
|
||||||
|
'Expected {} to return a status code of 200 but returned status '
|
||||||
|
'code "{}" instead with content "{}".'.format(
|
||||||
|
playbook_url, res.status_code, res.content)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
self.save_playbook(proj_path, item, res.content)
|
||||||
|
|
||||||
self.write_version(proj_path, version)
|
self.write_version(proj_path, version)
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function (data, $scope, moment, $state, InventoryData, InsightsService,
|
|||||||
InventoryData.summary_fields.insights_credential && InventoryData.summary_fields.insights_credential.id) ?
|
InventoryData.summary_fields.insights_credential && InventoryData.summary_fields.insights_credential.id) ?
|
||||||
InventoryData.summary_fields.insights_credential.id : null;
|
InventoryData.summary_fields.insights_credential.id : null;
|
||||||
$scope.canRemediate = CanRemediate;
|
$scope.canRemediate = CanRemediate;
|
||||||
|
$scope.platformId = $scope.reports_dataset.platform_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filter(str){
|
function filter(str){
|
||||||
@@ -40,7 +41,7 @@ function (data, $scope, moment, $state, InventoryData, InsightsService,
|
|||||||
};
|
};
|
||||||
|
|
||||||
$scope.viewDataInInsights = function(){
|
$scope.viewDataInInsights = function(){
|
||||||
window.open(`https://access.redhat.com/insights/inventory?machine=${$scope.$parent.host.insights_system_id}`, '_blank');
|
window.open(`https://cloud.redhat.com/insights/inventory/${$scope.platformId}/insights`, '_blank');
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.remediateInventory = function(inv_id, insights_credential){
|
$scope.remediateInventory = function(inv_id, insights_credential){
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
export default function(){
|
export default function(){
|
||||||
return function(plan) {
|
return function(plan) {
|
||||||
if(plan === null || plan === undefined){
|
if(plan === null || plan === undefined){
|
||||||
return "PLAN: Not Available <a href='https://access.redhat.com/insights/info/' target='_blank'>CREATE A NEW PLAN IN INSIGHTS</a>";
|
return "PLAN: Not Available <a href='https://cloud.redhat.com/insights/remediations/' target='_blank'>CREATE A NEW PLAN IN INSIGHTS</a>";
|
||||||
} else {
|
} else {
|
||||||
let name = (plan.maintenance_plan.name === null) ? "Unnamed Plan" : plan.maintenance_plan.name;
|
let name = (plan.name === null) ? "Unnamed Plan" : plan.name;
|
||||||
return `<a href="https://access.redhat.com/insights/planner/${plan.maintenance_plan.maintenance_id}" target="_blank">${name} (${plan.maintenance_plan.maintenance_id})</a>`;
|
return `<a href="https://cloud.redhat.com/insights/remediations/${plan.id}" target="_blank">${name} (${plan.id})</a>`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user