From 87eea59845d5b3357cda6298349849f6c99a376f Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Thu, 25 May 2017 17:53:51 -0400 Subject: [PATCH] add per-inventory insights credential --- awx/api/serializers.py | 5 ++- awx/api/views.py | 26 ++++++------ awx/main/migrations/0038_v320_release.py | 10 +++++ .../0040_v320_add_credentialtype_model.py | 2 +- awx/main/models/credential.py | 32 +++++++++++++- awx/main/models/inventory.py | 15 +++++++ awx/main/models/projects.py | 5 ++- .../tests/functional/api/test_inventory.py | 14 +++++++ awx/main/tests/functional/api/test_project.py | 15 +++++++ awx/main/tests/functional/conftest.py | 38 +++++++++++++++++ awx/main/tests/functional/test_credential.py | 1 + awx/main/tests/unit/api/test_views.py | 42 ++++++++++++++++++- 12 files changed, 185 insertions(+), 20 deletions(-) create mode 100644 awx/main/tests/functional/api/test_project.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 7045f5c037..57df11ef09 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1112,7 +1112,8 @@ class InventorySerializer(BaseSerializerWithVariables): fields = ('*', 'organization', 'kind', 'host_filter', 'variables', 'has_active_failures', 'total_hosts', 'hosts_with_active_failures', 'total_groups', 'groups_with_active_failures', 'has_inventory_sources', - 'total_inventory_sources', 'inventory_sources_with_failures') + 'total_inventory_sources', 'inventory_sources_with_failures', + 'insights_credential',) def get_related(self, obj): res = super(InventorySerializer, self).get_related(obj) @@ -1133,6 +1134,8 @@ class InventorySerializer(BaseSerializerWithVariables): object_roles = self.reverse('api:inventory_object_roles_list', kwargs={'pk': obj.pk}), instance_groups = self.reverse('api:inventory_instance_groups_list', kwargs={'pk': obj.pk}), )) + if obj.insights_credential: + res['insights_credential'] = self.reverse('api:credential_detail', kwargs={'pk': obj.insights_credential.pk}) if obj.organization: res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk}) return res diff --git a/awx/api/views.py b/awx/api/views.py index b61db39437..89b682bdbd 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -14,7 +14,6 @@ import subprocess import sys import logging import requests -import urlparse from base64 import b64encode from collections import OrderedDict @@ -2088,37 +2087,36 @@ class HostInsights(GenericAPIView): 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_500_INTERNAL_SERVER_ERROR) + return (dict(error=_('SSLError while trying to connect to {}').format(url)), status.HTTP_500_INTERNAL_SERVER_ERROR) except requests.exceptions.Timeout: - return (dict(error='Request to {} timed out.'.format(url)), status.HTTP_504_GATEWAY_TIMEOUT) + return (dict(error=_('Request to {} timed out.').format(url)), status.HTTP_504_GATEWAY_TIMEOUT) except requests.exceptions.RequestException as e: - return (dict(error='Unkown exception {} while trying to GET {}'.format(e, url)), status.HTTP_500_INTERNAL_SERVER_ERROR) + return (dict(error=_('Unkown exception {} while trying to GET {}').format(e, url)), status.HTTP_500_INTERNAL_SERVER_ERROR) if res.status_code != 200: - return (dict(error='Failed to gather reports and maintenance plans from Insights API. Server responded with {} status code and message {}'.format(res.status_code, res.content)), status.HTTP_500_INTERNAL_SERVER_ERROR) + 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_500_INTERNAL_SERVER_ERROR) try: return (dict(insights_content=res.json()), status.HTTP_200_OK) except ValueError: - return (dict(error='Expected JSON response from Insights but instead got {}'.format(res.content)), status.HTTP_500_INTERNAL_SERVER_ERROR) + return (dict(error=_('Expected JSON response from Insights but instead got {}').format(res.content)), status.HTTP_500_INTERNAL_SERVER_ERROR) 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) - creds = Credential.objects.filter(credential_type__name='Red Hat Satellite 6', credential_type__kind='cloud', credential_type__managed_by_tower=True) - if creds.count() > 0: - cred = creds[0] + if host.inventory and host.inventory.insights_credential: + cred = host.inventory.insights_credential else: - return Response(dict(error='No Insights Credential found for the Inventory, "{}", that this host belongs to.'.format(host.inventory.name)), status=status.HTTP_404_NOT_FOUND) + return Response(dict(error=_('No Insights Credential found for the Inventory, "{}", that this host belongs to.').format(host.inventory.name)), status=status.HTTP_404_NOT_FOUND) - url = 'https://access.redhat.com/r/insights/v3/systems/{}/reports/'.format(host.insights_system_id) + url = settings.INSIGHTS_URL_BASE + '/r/insights/v3/systems/{}/reports/'.format(host.insights_system_id) (username, password) = self._extract_insights_creds(cred) - (msg, status) = self.get_insights(url, username, password) - return Response(msg, status=status) + (msg, err_code) = self.get_insights(url, username, password) + return Response(msg, status=err_code) class GroupList(ListCreateAPIView): diff --git a/awx/main/migrations/0038_v320_release.py b/awx/main/migrations/0038_v320_release.py index afc1e7a1ad..8216282e91 100644 --- a/awx/main/migrations/0038_v320_release.py +++ b/awx/main/migrations/0038_v320_release.py @@ -243,4 +243,14 @@ class Migration(migrations.Migration): name='insights_system_id', field=models.TextField(default=None, help_text='Red Hat Insights host unique identifier.', null=True, db_index=True, blank=True), ), + migrations.AddField( + model_name='inventory', + name='insights_credential', + field=models.ForeignKey(related_name='insights_credential', default=None, blank=True, on_delete=models.deletion.SET_NULL, to='main.Credential', help_text='Credentials to be used by hosts belonging to this invtory when accessing Red Hat Insights API.', null=True), + ), + migrations.AlterField( + model_name='inventory', + name='kind', + field=models.CharField(default=b'', help_text='Kind of inventory being represented.', max_length=32, blank=True, choices=[(b'', 'Hosts have a direct link to this inventory.'), (b'smart', 'Hosts for inventory generated using the host_filter property.')]), + ), ] diff --git a/awx/main/migrations/0040_v320_add_credentialtype_model.py b/awx/main/migrations/0040_v320_add_credentialtype_model.py index 326f9c6d23..9dfef86862 100644 --- a/awx/main/migrations/0040_v320_add_credentialtype_model.py +++ b/awx/main/migrations/0040_v320_add_credentialtype_model.py @@ -23,7 +23,7 @@ class Migration(migrations.Migration): ('modified', models.DateTimeField(default=None, editable=False)), ('description', models.TextField(default=b'', blank=True)), ('name', models.CharField(max_length=512)), - ('kind', models.CharField(max_length=32, choices=[(b'ssh', 'SSH'), (b'vault', 'Vault'), (b'net', 'Network'), (b'scm', 'Source Control'), (b'cloud', 'Cloud')])), + ('kind', models.CharField(max_length=32, choices=[(b'ssh', 'SSH'), (b'vault', 'Vault'), (b'net', 'Network'), (b'scm', 'Source Control'), (b'cloud', 'Cloud'), (b'insights', 'Insights')])), ('managed_by_tower', models.BooleanField(default=False, editable=False)), ('inputs', awx.main.fields.CredentialTypeInputField(default={}, blank=True)), ('injectors', awx.main.fields.CredentialTypeInjectorField(default={}, blank=True)), diff --git a/awx/main/models/credential.py b/awx/main/models/credential.py index 804ed49798..458635f858 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -399,7 +399,8 @@ class CredentialType(CommonModelNameNotUnique): ('vault', _('Vault')), ('net', _('Network')), ('scm', _('Source Control')), - ('cloud', _('Cloud')) + ('cloud', _('Cloud')), + ('insights', _('Insights')), ) kind = models.CharField( @@ -919,3 +920,32 @@ def azure_rm(cls): }] } ) + + +@CredentialType.default +def insights(cls): + return cls( + kind='insights', + name='Insights Basic Auth', + managed_by_tower=True, + inputs={ + 'fields': [{ + 'id': 'username', + 'label': 'Basic Auth Username', + 'type': 'string' + }, { + 'id': 'password', + 'label': 'Basic Auth Password', + 'type': 'string', + 'secret': True + }], + 'required': ['username', 'password'], + }, + injectors={ + 'extra_vars': { + "scm_username": "{{username}}", + "scm_password": "{{password}}", + }, + }, + ) + diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 4b4f867a44..f1ddb1b1d3 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -143,6 +143,16 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin): 'use_role', 'admin_role', ]) + insights_credential = models.ForeignKey( + 'Credential', + related_name='insights_credential', + help_text=_('Credentials to be used by hosts belonging to this invtory when accessing Red Hat Insights API.'), + on_delete=models.SET_NULL, + blank=True, + null=True, + default=None, + ) + def get_absolute_url(self, request=None): return reverse('api:inventory_detail', kwargs={'pk': self.pk}, request=request) @@ -345,6 +355,11 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin): group_pks = self.groups.values_list('pk', flat=True) return self.groups.exclude(parents__pk__in=group_pks).distinct() + def clean_insights_credential(self): + if self.insights_credential and self.insights_credential.credential_type.kind != 'insights': + raise ValidationError(_('Invalid CredentialType')) + return self.insights_credential + class Host(CommonModelNameNotUnique): ''' diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index 549b6c01bc..7a224c8d43 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -141,7 +141,10 @@ class ProjectOptions(models.Model): return None cred = self.credential if cred: - if cred.kind != 'scm': + if self.scm_type == 'insights': + if cred.kind != 'insights': + raise ValidationError(_("Credential kind must be 'insights'.")) + elif cred.kind != 'scm': raise ValidationError(_("Credential kind must be 'scm'.")) try: if self.scm_type == 'insights': diff --git a/awx/main/tests/functional/api/test_inventory.py b/awx/main/tests/functional/api/test_inventory.py index ddfaa4f562..8f71544f98 100644 --- a/awx/main/tests/functional/api/test_inventory.py +++ b/awx/main/tests/functional/api/test_inventory.py @@ -287,3 +287,17 @@ class TestControlledBySCM: r = options(reverse('api:inventory_inventory_sources_list', kwargs={'pk': scm_inventory.id}), admin_user, expect=200) assert 'POST' not in r.data['actions'] + + +@pytest.mark.django_db +class TestInsightsCredential: + def test_insights_credential(self, patch, insights_inventory, admin_user, insights_credential): + patch(insights_inventory.get_absolute_url(), + {'insights_credential': insights_credential.id}, admin_user, + expect=200) + + def test_non_insights_credential(self, patch, insights_inventory, admin_user, scm_credential): + patch(insights_inventory.get_absolute_url(), + {'insights_credential': scm_credential.id}, admin_user, + expect=400) + diff --git a/awx/main/tests/functional/api/test_project.py b/awx/main/tests/functional/api/test_project.py new file mode 100644 index 0000000000..ddf28f9b52 --- /dev/null +++ b/awx/main/tests/functional/api/test_project.py @@ -0,0 +1,15 @@ +import pytest + + +@pytest.mark.django_db +class TestInsightsCredential: + def test_insights_credential(self, patch, insights_project, admin_user, insights_credential): + patch(insights_project.get_absolute_url(), + {'credential': insights_credential.id}, admin_user, + expect=200) + + def test_non_insights_credential(self, patch, insights_project, admin_user, scm_credential): + patch(insights_project.get_absolute_url(), + {'credential': scm_credential.id}, admin_user, + expect=400) + diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 963466009d..caf03b56e8 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -178,6 +178,11 @@ def user_project(user): return Project.objects.create(name="test-user-project", created_by=owner, description="test-user-project-desc") +@pytest.fixture +def insights_project(): + return Project.objects.create(name="test-insights-project", scm_type="insights") + + @pytest.fixture def instance(settings): return Instance.objects.create(uuid=settings.SYSTEM_UUID, hostname="instance.example.org", capacity=100) @@ -216,6 +221,20 @@ def credentialtype_vault(): return vault_type +@pytest.fixture +def credentialtype_scm(): + scm_type = CredentialType.defaults['scm']() + scm_type.save() + return scm_type + + +@pytest.fixture +def credentialtype_insights(): + insights_type = CredentialType.defaults['insights']() + insights_type.save() + return insights_type + + @pytest.fixture def credential(credentialtype_aws): return Credential.objects.create(credential_type=credentialtype_aws, name='test-cred', @@ -240,6 +259,18 @@ def machine_credential(credentialtype_ssh): inputs={'username': 'test_user', 'password': 'pas4word'}) +@pytest.fixture +def scm_credential(credentialtype_scm): + return Credential.objects.create(credential_type=credentialtype_scm, name='scm-cred', + inputs={'username': 'optimus', 'password': 'prime'}) + + +@pytest.fixture +def insights_credential(credentialtype_insights): + return Credential.objects.create(credential_type=credentialtype_insights, name='insights-cred', + inputs={'username': 'morocco_mole', 'password': 'secret_squirrel'}) + + @pytest.fixture def org_credential(organization, credentialtype_aws): return Credential.objects.create(credential_type=credentialtype_aws, name='test-cred', @@ -252,6 +283,13 @@ def inventory(organization): return organization.inventories.create(name="test-inv") +@pytest.fixture +def insights_inventory(inventory): + inventory.scm_type = 'insights' + inventory.save() + return inventory + + @pytest.fixture def scm_inventory_source(inventory, project): inv_src = InventorySource( diff --git a/awx/main/tests/functional/test_credential.py b/awx/main/tests/functional/test_credential.py index 0a625b6206..534bd2785f 100644 --- a/awx/main/tests/functional/test_credential.py +++ b/awx/main/tests/functional/test_credential.py @@ -19,6 +19,7 @@ def test_default_cred_types(): 'azure_rm', 'cloudforms', 'gce', + 'insights', 'net', 'openstack', 'satellite6', diff --git a/awx/main/tests/unit/api/test_views.py b/awx/main/tests/unit/api/test_views.py index 837d202044..eb960233cd 100644 --- a/awx/main/tests/unit/api/test_views.py +++ b/awx/main/tests/unit/api/test_views.py @@ -12,6 +12,11 @@ from awx.api.views import ( HostInsights, ) +from awx.main.models import ( + Host, + Inventory, +) + @pytest.fixture def mock_response_new(mocker): @@ -143,10 +148,10 @@ class TestHostInsights(): 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, 'hello world!')) + 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. Server responded with 500 status code and message hello world!' + 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_malformed_json_content(self, patch_parent, mocker): view = HostInsights() @@ -164,3 +169,36 @@ class TestHostInsights(): assert msg['error'] == 'Expected JSON response from Insights but instead got booo!' assert code == 500 + #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'] == 'No Insights Credential found for the Inventory, "inventory_name_here", that this host belongs to.' + assert resp.status_code == 404