mirror of
https://github.com/ZwareBear/awx.git
synced 2026-04-21 07:21:49 -05:00
Merge branch 'downstream' into devel
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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']
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
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 -*-
|
||||
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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
1
awx/ui/test/e2e/e2e-pipeline.groovy
Normal file
1
awx/ui/test/e2e/e2e-pipeline.groovy
Normal file
@@ -0,0 +1 @@
|
||||
e2ePipeline()
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user