add per-inventory insights credential

This commit is contained in:
Chris Meyers
2017-05-25 17:53:51 -04:00
parent ca1eb28d1f
commit 87eea59845
12 changed files with 185 additions and 20 deletions

View File

@@ -1112,7 +1112,8 @@ class InventorySerializer(BaseSerializerWithVariables):
fields = ('*', 'organization', 'kind', 'host_filter', 'variables', 'has_active_failures', fields = ('*', 'organization', 'kind', 'host_filter', 'variables', 'has_active_failures',
'total_hosts', 'hosts_with_active_failures', 'total_groups', 'total_hosts', 'hosts_with_active_failures', 'total_groups',
'groups_with_active_failures', 'has_inventory_sources', '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): def get_related(self, obj):
res = super(InventorySerializer, self).get_related(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}), 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}), 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: if obj.organization:
res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk}) res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk})
return res return res

View File

@@ -14,7 +14,6 @@ import subprocess
import sys import sys
import logging import logging
import requests import requests
import urlparse
from base64 import b64encode from base64 import b64encode
from collections import OrderedDict from collections import OrderedDict
@@ -2088,37 +2087,36 @@ class HostInsights(GenericAPIView):
try: try:
res = self._get_insights(url, username, password) res = self._get_insights(url, username, password)
except requests.exceptions.SSLError: 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: 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: 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: 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: try:
return (dict(insights_content=res.json()), status.HTTP_200_OK) return (dict(insights_content=res.json()), status.HTTP_200_OK)
except ValueError: 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): def get(self, request, *args, **kwargs):
host = self.get_object() host = self.get_object()
cred = None cred = None
if host.insights_system_id is None: if host.insights_system_id is None:
return Response(dict(error='This host is not recognized as an Insights host.'), status=status.HTTP_404_NOT_FOUND) return Response(dict(error=_('This host is not recognized as an Insights host.')), status=status.HTTP_404_NOT_FOUND)
creds = Credential.objects.filter(credential_type__name='Red Hat Satellite 6', credential_type__kind='cloud', credential_type__managed_by_tower=True) if host.inventory and host.inventory.insights_credential:
if creds.count() > 0: cred = host.inventory.insights_credential
cred = creds[0]
else: 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) (username, password) = self._extract_insights_creds(cred)
(msg, status) = self.get_insights(url, username, password) (msg, err_code) = self.get_insights(url, username, password)
return Response(msg, status=status) return Response(msg, status=err_code)
class GroupList(ListCreateAPIView): class GroupList(ListCreateAPIView):

View File

@@ -243,4 +243,14 @@ class Migration(migrations.Migration):
name='insights_system_id', name='insights_system_id',
field=models.TextField(default=None, help_text='Red Hat Insights host unique identifier.', null=True, db_index=True, blank=True), 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.')]),
),
] ]

View File

@@ -23,7 +23,7 @@ class Migration(migrations.Migration):
('modified', models.DateTimeField(default=None, editable=False)), ('modified', models.DateTimeField(default=None, editable=False)),
('description', models.TextField(default=b'', blank=True)), ('description', models.TextField(default=b'', blank=True)),
('name', models.CharField(max_length=512)), ('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)), ('managed_by_tower', models.BooleanField(default=False, editable=False)),
('inputs', awx.main.fields.CredentialTypeInputField(default={}, blank=True)), ('inputs', awx.main.fields.CredentialTypeInputField(default={}, blank=True)),
('injectors', awx.main.fields.CredentialTypeInjectorField(default={}, blank=True)), ('injectors', awx.main.fields.CredentialTypeInjectorField(default={}, blank=True)),

View File

@@ -399,7 +399,8 @@ class CredentialType(CommonModelNameNotUnique):
('vault', _('Vault')), ('vault', _('Vault')),
('net', _('Network')), ('net', _('Network')),
('scm', _('Source Control')), ('scm', _('Source Control')),
('cloud', _('Cloud')) ('cloud', _('Cloud')),
('insights', _('Insights')),
) )
kind = models.CharField( 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}}",
},
},
)

View File

@@ -143,6 +143,16 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin):
'use_role', 'use_role',
'admin_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): def get_absolute_url(self, request=None):
return reverse('api:inventory_detail', kwargs={'pk': self.pk}, request=request) 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) group_pks = self.groups.values_list('pk', flat=True)
return self.groups.exclude(parents__pk__in=group_pks).distinct() 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): class Host(CommonModelNameNotUnique):
''' '''

View File

@@ -141,7 +141,10 @@ class ProjectOptions(models.Model):
return None return None
cred = self.credential cred = self.credential
if cred: 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'.")) raise ValidationError(_("Credential kind must be 'scm'."))
try: try:
if self.scm_type == 'insights': if self.scm_type == 'insights':

View File

@@ -287,3 +287,17 @@ class TestControlledBySCM:
r = options(reverse('api:inventory_inventory_sources_list', kwargs={'pk': scm_inventory.id}), r = options(reverse('api:inventory_inventory_sources_list', kwargs={'pk': scm_inventory.id}),
admin_user, expect=200) admin_user, expect=200)
assert 'POST' not in r.data['actions'] 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)

View File

@@ -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)

View File

@@ -178,6 +178,11 @@ def user_project(user):
return Project.objects.create(name="test-user-project", created_by=owner, description="test-user-project-desc") 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 @pytest.fixture
def instance(settings): def instance(settings):
return Instance.objects.create(uuid=settings.SYSTEM_UUID, hostname="instance.example.org", capacity=100) return Instance.objects.create(uuid=settings.SYSTEM_UUID, hostname="instance.example.org", capacity=100)
@@ -216,6 +221,20 @@ def credentialtype_vault():
return vault_type 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 @pytest.fixture
def credential(credentialtype_aws): def credential(credentialtype_aws):
return Credential.objects.create(credential_type=credentialtype_aws, name='test-cred', 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'}) 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 @pytest.fixture
def org_credential(organization, credentialtype_aws): def org_credential(organization, credentialtype_aws):
return Credential.objects.create(credential_type=credentialtype_aws, name='test-cred', 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") return organization.inventories.create(name="test-inv")
@pytest.fixture
def insights_inventory(inventory):
inventory.scm_type = 'insights'
inventory.save()
return inventory
@pytest.fixture @pytest.fixture
def scm_inventory_source(inventory, project): def scm_inventory_source(inventory, project):
inv_src = InventorySource( inv_src = InventorySource(

View File

@@ -19,6 +19,7 @@ def test_default_cred_types():
'azure_rm', 'azure_rm',
'cloudforms', 'cloudforms',
'gce', 'gce',
'insights',
'net', 'net',
'openstack', 'openstack',
'satellite6', 'satellite6',

View File

@@ -12,6 +12,11 @@ from awx.api.views import (
HostInsights, HostInsights,
) )
from awx.main.models import (
Host,
Inventory,
)
@pytest.fixture @pytest.fixture
def mock_response_new(mocker): def mock_response_new(mocker):
@@ -143,10 +148,10 @@ class TestHostInsights():
def test_get_insights_non_200(self, patch_parent, mocker): def test_get_insights_non_200(self, patch_parent, mocker):
view = HostInsights() view = HostInsights()
Response = namedtuple('Response', 'status_code content') 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') (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): def test_get_insights_malformed_json_content(self, patch_parent, mocker):
view = HostInsights() view = HostInsights()
@@ -164,3 +169,36 @@ class TestHostInsights():
assert msg['error'] == 'Expected JSON response from Insights but instead got booo!' assert msg['error'] == 'Expected JSON response from Insights but instead got booo!'
assert code == 500 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