Merge branch 'downstream' into devel

This commit is contained in:
Ryan Petrello
2019-04-26 08:02:04 -04:00
40 changed files with 1133 additions and 1185 deletions

View File

@@ -31,7 +31,7 @@ from django.utils.translation import ugettext_lazy as _
# 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.permissions import AllowAny, IsAuthenticated
from rest_framework.renderers import JSONRenderer, StaticHTMLRenderer
@@ -1613,17 +1613,58 @@ class HostActivityStreamList(SubListAPIView):
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):
model = models.Host
serializer_class = serializers.EmptySerializer
def _extract_insights_creds(self, credential):
return (credential.get_input('username', default=''), credential.get_input('password', default=''))
def _call_insights_api(self, url, session, headers):
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.auth = requests.auth.HTTPBasicAuth(username, password)
return session
def _get_headers(self):
license = get_license(show_key=False).get('license_type', 'UNLICENSED')
headers = {
'Content-Type': 'application/json',
@@ -1633,47 +1674,84 @@ class HostInsights(GenericAPIView):
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:
res = self._get_insights(url, username, password)
except requests.exceptions.SSLError:
return (dict(error=_('SSLError while trying to connect to {}').format(url)), status.HTTP_502_BAD_GATEWAY)
except requests.exceptions.Timeout:
return (dict(error=_('Request to {} timed out.').format(url)), status.HTTP_504_GATEWAY_TIMEOUT)
except requests.exceptions.RequestException as e:
return (dict(error=_('Unknown exception {} while trying to GET {}').format(e, url)), status.HTTP_502_BAD_GATEWAY)
res['results'][0]['id']
except (IndexError, KeyError):
raise NotFound(
_('Could not translate Insights system ID {}'
' into an Insights platform ID.').format(host.insights_system_id))
if res.status_code == 401:
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)
return res['results'][0]
try:
filtered_insights_content = filter_insights_api_response(res.json())
return (dict(insights_content=filtered_insights_content), status.HTTP_200_OK)
except ValueError:
return (dict(error=_('Expected JSON response from Insights but instead got {}').format(res.content)), status.HTTP_502_BAD_GATEWAY)
def _get_reports(self, platform_id, session, headers):
url = '{}/api/insights/v1/system/{}/reports/'.format(
settings.INSIGHTS_URL_BASE, platform_id)
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):
host = self.get_object()
cred = 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:
cred = host.inventory.insights_credential
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, password) = self._extract_insights_creds(cred)
(msg, err_code) = self.get_insights(url, username, password)
return Response(msg, status=err_code)
username = cred.get_input('username', default='')
password = cred.get_input('password', default='')
session = self._get_session(username, password)
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):

View File

@@ -213,7 +213,7 @@ def copy_tables(since, full_path):
main_jobevent.uuid,
main_jobevent.parent_uuid,
main_jobevent.event,
main_jobevent.event_data::json->'task_action',
main_jobevent.event_data::json->'task_action' AS task_action,
main_jobevent.failed,
main_jobevent.changed,
main_jobevent.playbook,
@@ -225,7 +225,7 @@ def copy_tables(since, full_path):
main_jobevent.host_name
FROM main_jobevent
WHERE main_jobevent.created > {}
ORDER BY main_jobevent.id ASC) to stdout'''.format(since.strftime("'%Y-%m-%d %H:%M:%S'"))
ORDER BY main_jobevent.id ASC) TO STDOUT WITH CSV HEADER'''.format(since.strftime("'%Y-%m-%d %H:%M:%S'"))
_copy_table(table='events', query=events_query, path=full_path)
unified_job_query = '''COPY (SELECT main_unifiedjob.id,
@@ -250,7 +250,7 @@ def copy_tables(since, full_path):
WHERE main_unifiedjob.created > {} AND
main_unifiedjob.polymorphic_ctype_id = django_content_type.id AND
main_unifiedjob.launch_type != 'sync'
ORDER BY main_unifiedjob.id ASC) to stdout'''.format(since.strftime("'%Y-%m-%d %H:%M:%S'"))
ORDER BY main_unifiedjob.id ASC) TO STDOUT WITH CSV HEADER'''.format(since.strftime("'%Y-%m-%d %H:%M:%S'"))
_copy_table(table='unified_jobs', query=unified_job_query, path=full_path)
unified_job_template_query = '''COPY (SELECT main_unifiedjobtemplate.id,
@@ -270,7 +270,7 @@ def copy_tables(since, full_path):
main_unifiedjobtemplate.status
FROM main_unifiedjobtemplate, django_content_type
WHERE main_unifiedjobtemplate.polymorphic_ctype_id = django_content_type.id
ORDER BY main_unifiedjobtemplate.id ASC) to stdout'''.format(since.strftime("'%Y-%m-%d %H:%M:%S'"))
ORDER BY main_unifiedjobtemplate.id ASC) TO STDOUT WITH CSV HEADER'''.format(since.strftime("'%Y-%m-%d %H:%M:%S'"))
_copy_table(table='unified_job_template', query=unified_job_template_query, path=full_path)
return

View File

@@ -44,6 +44,9 @@ INSTANCE_INFO = Info('awx_instance', 'Info about each node in a Tower system', [
INSTANCE_LAUNCH_TYPE = Gauge('awx_instance_launch_type_total', 'Type of Job launched', ['node', 'launch_type',])
INSTANCE_STATUS = Gauge('awx_instance_status_total', 'Status of Job launched', ['node', 'status',])
LICENSE_INSTANCE_TOTAL = Gauge('awx_license_instance_total', 'Total number of managed hosts provided by your license')
LICENSE_INSTANCE_FREE = Gauge('awx_license_instance_free', 'Number of remaining managed hosts provided by your license')
def metrics():
license_info = get_license(show_key=False)
@@ -54,13 +57,15 @@ def metrics():
'tower_version': get_awx_version(),
'ansible_version': get_ansible_version(),
'license_type': license_info.get('license_type', 'UNLICENSED'),
'free_instances': str(license_info.get('free instances', 0)),
'license_expiry': str(license_info.get('time_remaining', 0)),
'pendo_tracking': settings.PENDO_TRACKING_STATE,
'external_logger_enabled': str(settings.LOG_AGGREGATOR_ENABLED),
'external_logger_type': getattr(settings, 'LOG_AGGREGATOR_TYPE', 'None')
})
LICENSE_INSTANCE_TOTAL.set(str(license_info.get('available_instances', 0)))
LICENSE_INSTANCE_FREE.set(str(license_info.get('free_instances', 0)))
current_counts = counts(None)
ORG_COUNT.set(current_counts['organization'])

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,11 @@ import os
dir_path = os.path.dirname(os.path.realpath(__file__))
with open(os.path.join(dir_path, 'insights.json')) as data_file:
TEST_INSIGHTS_PLANS = json.loads(data_file.read())
with open(os.path.join(dir_path, 'insights_hosts.json')) as data_file:
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']

View 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"
}
]
}

View 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
}
}

View File

@@ -28,6 +28,8 @@ EXPECTED_VALUES = {
'awx_instance_cpu':0.0,
'awx_instance_memory':0.0,
'awx_instance_info':1.0,
'awx_license_instance_total':0,
'awx_license_instance_free':0,
}

View 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

View File

@@ -1,7 +1,5 @@
# -*- coding: utf-8 -*-
import re
import pytest
import requests
from copy import deepcopy
from unittest import mock
@@ -11,13 +9,9 @@ from awx.api.views import (
ApiVersionRootView,
JobTemplateLabelList,
InventoryInventorySourcesUpdate,
HostInsights,
JobTemplateSurveySpec
)
from awx.main.models import (
Host,
)
from awx.main.views import handle_error
from rest_framework.test import APIRequestFactory
@@ -122,103 +116,6 @@ class TestInventoryInventorySourcesUpdate:
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:
def test_create_text_encrypted(self):

View File

@@ -3,22 +3,25 @@
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():
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 len(actual['reports']) == 9
assert actual['reports'][0]['maintenance_actions'][0]['maintenance_plan']['name'] == "RHEL Demo Infrastructure"
assert actual['reports'][0]['maintenance_actions'][0]['maintenance_plan']['maintenance_id'] == 29315
assert actual['reports'][0]['rule']['severity'] == 'ERROR'
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 actual['last_check_in'] == '2019-03-19T21:59:09.213151-04:00'
assert len(actual['reports']) == 5
assert len(actual['reports'][0]['maintenance_actions']) == 1
assert actual['reports'][0]['maintenance_actions'][0]['name'] == "Fix Critical CVEs"
rule = actual['reports'][0]['rule']
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")

View File

@@ -2,42 +2,46 @@
# All Rights Reserved.
def filter_insights_api_response(json):
new_json = {}
'''
'last_check_in',
'reports.[].rule.severity',
'reports.[].rule.description',
'reports.[].rule.category',
'reports.[].rule.summary',
'reports.[].rule.ansible_fix',
'reports.[].rule.ansible',
'reports.[].maintenance_actions.[].maintenance_plan.name',
'reports.[].maintenance_actions.[].maintenance_plan.maintenance_id',
'''
# Old Insights API -> New API
#
# last_check_in is missing entirely, is now provided by a different endpoint
# reports[] -> []
# reports[].rule.{description,summary} -> [].rule.{description,summary}
# reports[].rule.category -> [].rule.category.name
# reports[].rule.severity (str) -> [].rule.total_risk (int)
if 'last_check_in' in json:
new_json['last_check_in'] = json['last_check_in']
if 'reports' in json:
new_json['reports'] = []
for rep in json['reports']:
new_report = {
'rule': {},
'maintenance_actions': []
}
if 'rule' in rep:
for k in ['severity', 'description', 'category', 'summary', 'ansible_fix', 'ansible',]:
if k in rep['rule']:
new_report['rule'][k] = rep['rule'][k]
# reports[].rule.{ansible,ansible_fix} appears to be unused
# reports[].maintenance_actions[] missing entirely, is now provided
# by a different Insights endpoint
def filter_insights_api_response(platform_info, reports, remediations):
severity_mapping = {
1: 'INFO',
2: 'WARN',
3: 'ERROR',
4: 'CRITICAL'
}
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

View File

@@ -2,6 +2,8 @@ from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
import os
import re
import requests
from ansible.plugins.action import ActionBase
@@ -9,8 +11,11 @@ from ansible.plugins.action import ActionBase
class ActionModule(ActionBase):
def save_playbook(self, proj_path, plan, content):
fname = '{}-{}.yml'.format(plan.get('name', None) or 'insights-plan', plan['maintenance_id'])
def save_playbook(self, proj_path, remediation, content):
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)
with open(file_path, 'wb') as f:
f.write(content)
@@ -18,9 +23,8 @@ class ActionModule(ActionBase):
def is_stale(self, proj_path, etag):
file_path = os.path.join(proj_path, '.version')
try:
f = open(file_path, 'r')
version = f.read()
f.close()
with open(file_path, 'r') as f:
version = f.read()
return version != etag
except IOError:
return True
@@ -31,7 +35,6 @@ class ActionModule(ActionBase):
f.write(etag)
def run(self, tmp=None, task_vars=None):
self._supports_check_mode = False
result = super(ActionModule, self).run(tmp, task_vars)
@@ -53,35 +56,10 @@ class ActionModule(ActionBase):
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:
result['failed'] = True
result['msg'] = (
@@ -89,7 +67,37 @@ class ActionModule(ActionBase):
'code "{}" instead with content "{}".'.format(url, res.status_code, res.content)
)
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)

View File

@@ -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.id : null;
$scope.canRemediate = CanRemediate;
$scope.platformId = $scope.reports_dataset.platform_id;
}
function filter(str){
@@ -40,7 +41,7 @@ function (data, $scope, moment, $state, InventoryData, InsightsService,
};
$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){

View File

@@ -7,10 +7,10 @@
export default function(){
return function(plan) {
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 {
let name = (plan.maintenance_plan.name === null) ? "Unnamed Plan" : plan.maintenance_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>`;
let name = (plan.name === null) ? "Unnamed Plan" : plan.name;
return `<a href="https://cloud.redhat.com/insights/remediations/${plan.id}" target="_blank">${name} (${plan.id})</a>`;
}
};
}

View File

@@ -7,10 +7,10 @@
export default ['$scope', '$location', '$stateParams', 'GenerateForm',
'ProjectsForm', 'Rest', 'Alert', 'ProcessErrors', 'GetBasePath',
'GetProjectPath', 'GetChoices', 'Wait', '$state', 'CreateSelect2', 'i18n',
'ConfigData', 'resolvedModels', 'scmCredentialType',
'ConfigData', 'resolvedModels', 'scmCredentialType', 'insightsCredentialType',
function($scope, $location, $stateParams, GenerateForm, ProjectsForm, Rest,
Alert, ProcessErrors, GetBasePath, GetProjectPath, GetChoices, Wait, $state,
CreateSelect2, i18n, ConfigData, resolvedModels, scmCredentialType) {
CreateSelect2, i18n, ConfigData, resolvedModels, scmCredentialType, insightsCredentialType) {
let form = ProjectsForm(),
base = $location.path().replace(/^\//, '').split('/')[0],
@@ -191,9 +191,13 @@ export default ['$scope', '$location', '$stateParams', 'GenerateForm',
$scope.lookupCredential = function(){
// Perform a lookup on the credential_type. Git, Mercurial, and Subversion
// all use SCM as their credential type.
let lookupCredentialType = scmCredentialType;
if ($scope.scm_type.value === 'insights') {
lookupCredentialType = insightsCredentialType;
}
$state.go('.credential', {
credential_search: {
credential_type: scmCredentialType,
credential_type: lookupCredentialType,
page_size: '5',
page: '1'
}

View File

@@ -8,12 +8,12 @@ export default ['$scope', '$rootScope', '$stateParams', 'ProjectsForm', 'Rest',
'Alert', 'ProcessErrors', 'GenerateForm', 'Prompt', 'isNotificationAdmin',
'GetBasePath', 'GetProjectPath', 'Authorization', 'GetChoices', 'Empty',
'Wait', 'ProjectUpdate', '$state', 'CreateSelect2', 'ToggleNotification',
'i18n', 'OrgAdminLookup', 'ConfigData', 'scmCredentialType',
'i18n', 'OrgAdminLookup', 'ConfigData', 'scmCredentialType', 'insightsCredentialType',
function($scope, $rootScope, $stateParams, ProjectsForm, Rest, Alert,
ProcessErrors, GenerateForm, Prompt, isNotificationAdmin, GetBasePath,
GetProjectPath, Authorization, GetChoices, Empty, Wait, ProjectUpdate,
$state, CreateSelect2, ToggleNotification, i18n, OrgAdminLookup,
ConfigData, scmCredentialType) {
ConfigData, scmCredentialType, insightsCredentialType) {
let form = ProjectsForm(),
defaultUrl = GetBasePath('projects') + $stateParams.project_id + '/',
@@ -310,10 +310,13 @@ export default ['$scope', '$rootScope', '$stateParams', 'ProjectsForm', 'Rest',
$scope.lookupCredential = function(){
// Perform a lookup on the credential_type. Git, Mercurial, and Subversion
// all use SCM as their credential type.
let lookupCredentialType = scmCredentialType;
if ($scope.scm_type.value === 'insights') {
lookupCredentialType = insightsCredentialType;
}
$state.go('.credential', {
credential_search: {
credential_type: scmCredentialType,
credential_type: lookupCredentialType,
page_size: '5',
page: '1'
}

View File

@@ -36,7 +36,23 @@ function ResolveScmCredentialType (GetBasePath, Rest, ProcessErrors) {
});
}
function ResolveInsightsCredentialType (GetBasePath, Rest, ProcessErrors) {
Rest.setUrl(GetBasePath('credential_types') + '?name=Insights');
return Rest.get()
.then(({ data }) => {
return data.results[0].id;
})
.catch(({ data, status }) => {
ProcessErrors(null, data, status, null, {
hdr: 'Error!',
msg: 'Failed to get credential type data: ' + status
});
});
}
ResolveScmCredentialType.$inject = ['GetBasePath', 'Rest', 'ProcessErrors'];
ResolveInsightsCredentialType.$inject = ['GetBasePath', 'Rest', 'ProcessErrors'];
export default
@@ -70,6 +86,7 @@ angular.module('Projects', [])
const stateIndex = res.states.findIndex(s => s.name === projectsAddName);
res.states[stateIndex].resolve.scmCredentialType = ResolveScmCredentialType;
res.states[stateIndex].resolve.insightsCredentialType = ResolveInsightsCredentialType;
return res;
});
@@ -113,6 +130,7 @@ angular.module('Projects', [])
const stateIndex = res.states.findIndex(s => s.name === projectsEditName);
res.states[stateIndex].resolve.scmCredentialType = ResolveScmCredentialType;
res.states[stateIndex].resolve.insightsCredentialType = ResolveInsightsCredentialType;
return res;
});

View File

@@ -5,7 +5,6 @@
exports.command = function logout () {
const logoutButton = '.at-Layout-topNav i.fa-power-off';
this
.waitForElementVisible(logoutButton)
.click(logoutButton)
.findThenClick(logoutButton, 'css')
.waitForElementPresent('#login-button');
};

View File

@@ -0,0 +1 @@
e2ePipeline()

View File

@@ -62,7 +62,7 @@ module.exports = {
this
.waitForElementVisible('#alert-modal-msg')
.expect.element('#alert-modal-msg').text.contain(application.name);
this.click('#alert_ok_btn');
this.findThenClick('#alert_ok_btn', 'css');
this.waitForElementNotVisible('#alert-modal-msg');
},
delete (name) {

View File

@@ -20,6 +20,7 @@ const store = {
lastName: `last-admin-${testID}`,
password: `admin-${testID}`,
username: `admin-${testID}`,
usernameDefault: `admin-${testID}`,
type: 'administrator',
},
auditor: {
@@ -28,6 +29,7 @@ const store = {
lastName: `last-auditor-${testID}`,
password: `auditor-${testID}`,
username: `auditor-${testID}`,
usernameDefault: `auditor-${testID}`,
type: 'auditor',
},
user: {
@@ -36,12 +38,20 @@ const store = {
lastName: `last-${testID}`,
password: `${testID}`,
username: `user-${testID}`,
usernameDefault: `user-${testID}`,
type: 'normal',
},
};
module.exports = {
before: (client, done) => {
// generate a unique username on each attempt.
const uniqueUser = uuid().substr(0, 8);
Object.keys(store).forEach(key => {
if ('username' in store[key]) {
store[key].username = `${store[key].usernameDefault}-${uniqueUser}`;
}
});
const resources = [
getOrganization(store.organization.name),
getAuditor(store.auditor.username),

View File

@@ -1,6 +1,7 @@
/* Websocket tests. These tests verify that items like the sparkline (colored box rows which
* display job status) and other status icons update correctly as the jobs progress.
*/
import uuid from 'uuid';
import {
getInventorySource,
@@ -160,13 +161,14 @@ module.exports = {
.to.be.present.before(AWX_E2E_TIMEOUT_ASYNC);
},
'Test pending deletion of inventories': client => {
getInventorySource('test-pending-delete');
const uniqueID = uuid().substr(0, 8);
getInventorySource(`test-pending-delete-${uniqueID}`);
client
.useCss()
.navigateTo(`${AWX_E2E_URL}/#/inventories`, false)
.waitForElementVisible('.SmartSearch-input')
.clearValue('.SmartSearch-input')
.setValue('.SmartSearch-input', ['test-pending-delete', client.Keys.ENTER])
.setValue('.SmartSearch-input', [`test-pending-delete-${uniqueID}`, client.Keys.ENTER])
.pause(AWX_E2E_TIMEOUT_SHORT) // helps prevent flake
.findThenClick('.fa-trash-o', 'css')
.waitForElementVisible('#prompt_action_btn')

View File

@@ -1,3 +1,5 @@
import uuid from 'uuid';
import {
getInventorySource,
getJobTemplate,
@@ -7,15 +9,12 @@ import {
import {
AWX_E2E_URL,
AWX_E2E_TIMEOUT_LONG
} from '../settings';
let data;
const spinny = "//*[contains(@class, 'spinny')]";
const workflowSelector = "//a[text()='test-actions-workflow-template']";
const workflowVisualizerBtn = "//button[contains(@id, 'workflow_job_template_workflow_visualizer_btn')]";
const workflowSearchBar = "//input[contains(@class, 'SmartSearch-input')]";
const workflowText = 'name.iexact:"test-actions-workflow-template"';
const startNodeId = '1';
let initialJobNodeId;
@@ -26,10 +25,6 @@ let leafNodeId;
const nodeAdd = "//*[contains(@class, 'WorkflowChart-nodeAddIcon')]";
const nodeRemove = "//*[contains(@class, 'WorkflowChart-nodeRemoveIcon')]";
// one of the jobs or projects or inventories
const testActionsJob = "//div[contains(@class, 'List-tableCell') and contains(text(), 'test-actions-job')]";
const testActionsJobText = 'name.iexact:"test-actions-job-template"';
// search bar for visualizer templates
const jobSearchBar = "//*[contains(@id, 'workflow-jobs-list')]//input[contains(@class, 'SmartSearch-input')]";
@@ -49,51 +44,50 @@ const deleteConfirmation = "//button[@ng-click='confirmDeleteNode()']";
const xPathNodeById = (id) => `//*[@id='node-${id}']`;
const xPathLinkById = (sourceId, targetId) => `//*[@id='link-${sourceId}-${targetId}']//*[contains(@class, 'WorkflowChart-linkPath')]`;
const xPathNodeByName = (name) => `//*[contains(@class, "WorkflowChart-nameText") and contains(text(), "${name}")]/..`;
module.exports = {
before: (client, done) => {
// Ensure deterministic state on retries
const testID = uuid().substr(0, 8);
const namespace = `test-actions-${testID}`;
const resources = [
getInventorySource('test-actions'),
getJobTemplate('test-actions'),
getProject('test-actions'),
getWorkflowTemplate('test-actions'),
getInventorySource(namespace),
getJobTemplate(namespace),
getProject(namespace),
getWorkflowTemplate(namespace),
];
Promise.all(resources)
.then(([source, template, project, workflow]) => {
data = { source, template, project, workflow };
.then(([inventory, template, project, workflow]) => {
data = { inventory, template, project, workflow };
client
.login()
.waitForAngular()
.resizeWindow(1200, 1000)
.navigateTo(`${AWX_E2E_URL}/#/templates`, false)
.useXpath()
.waitForElementVisible(workflowSearchBar)
.setValue(workflowSearchBar, [`name.iexact:"${data.workflow.name}"`])
.click('//*[contains(@class, "SmartSearch-searchButton")]')
.waitForSpinny(true)
.click(`//a[text()="${namespace}-workflow-template"]`)
.waitForElementVisible(workflowVisualizerBtn)
.click(workflowVisualizerBtn)
.waitForSpinny(true);
client.waitForElementVisible(xPathNodeByName(`${namespace}-job`));
// Grab the ids of the nodes
client.getAttribute(xPathNodeByName(`${namespace}-job`), 'id', (res) => {
initialJobNodeId = res.value.split('-')[1];
});
client.getAttribute(xPathNodeByName(`${namespace}-pro`), 'id', (res) => {
initialProjectNodeId = res.value.split('-')[1];
});
client.getAttribute(xPathNodeByName(`${namespace}-inv`), 'id', (res) => {
initialInventoryNodeId = res.value.split('-')[1];
});
done();
});
client
.login()
.waitForAngular()
.resizeWindow(1200, 1000)
.navigateTo(`${AWX_E2E_URL}/#/templates`, false)
.useXpath()
.waitForElementVisible(workflowSearchBar)
.setValue(workflowSearchBar, [workflowText])
.click('//*[contains(@class, "SmartSearch-searchButton")]')
.waitForSpinny(true)
.click('//*[contains(@class, "SmartSearch-clearAll")]')
.waitForSpinny(true)
.setValue(workflowSearchBar, [workflowText])
.click('//*[contains(@class, "SmartSearch-searchButton")]')
.waitForSpinny(true)
.click(workflowSelector)
.waitForSpinny(true)
.click(workflowVisualizerBtn);
client.waitForElementVisible('//*[contains(@class, "WorkflowChart-nameText") and contains(text(), "test-actions-job")]/..');
// Grab the ids of the nodes
client.getAttribute('//*[contains(@class, "WorkflowChart-nameText") and contains(text(), "test-actions-job")]/..', 'id', (res) => {
initialJobNodeId = res.value.split('-')[1];
});
client.getAttribute('//*[contains(@class, "WorkflowChart-nameText") and contains(text(), "test-actions-project")]/..', 'id', (res) => {
initialProjectNodeId = res.value.split('-')[1];
});
client.getAttribute('//*[contains(@class, "WorkflowChart-nameText") and contains(text(), "test-actions-inventory")]/..', 'id', (res) => {
initialInventoryNodeId = res.value.split('-')[1];
});
},
'verify that workflow visualizer new root node can only be set to always': client => {
client
@@ -143,9 +137,9 @@ module.exports = {
client
.waitForElementVisible(jobSearchBar)
.clearValue(jobSearchBar)
.setValue(jobSearchBar, [testActionsJobText, client.Keys.ENTER])
.setValue(jobSearchBar, [`name.iexact:"${data.template.name}"`, client.Keys.ENTER])
.pause(1000)
.findThenClick(testActionsJob)
.findThenClick(`//div[contains(@class, "List-tableCell") and contains(text(), "${data.template.name}")]`)
.pause(1000)
.waitForElementNotVisible(spinny)
.findThenClick(edgeTypeDropdownBar)
@@ -174,9 +168,9 @@ module.exports = {
client
.waitForElementVisible(jobSearchBar)
.clearValue(jobSearchBar)
.setValue(jobSearchBar, [testActionsJobText, client.Keys.ENTER])
.setValue(jobSearchBar, [`name.iexact:"${data.template.name}"`, client.Keys.ENTER])
.pause(1000)
.findThenClick(testActionsJob)
.findThenClick(`//div[contains(@class, "List-tableCell") and contains(text(), "${data.template.name}")]`)
.pause(1000)
.waitForElementNotVisible(spinny)
.findThenClick(edgeTypeDropdownBar)