From d0a848d49a1ae8bba7d2e98b37cc44363dfdbadb Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 3 May 2017 11:48:01 -0400 Subject: [PATCH] Add a new `extra_credentials` endpoint for Jobs and JobTemplates additionally, add backwards compatible support for `cloud_credential` and `network_credential` in /api/v1/job_templates/ and /api/v1/jobs/. see: #5807 --- awx/api/serializers.py | 74 +++++++++- awx/api/urls.py | 4 +- awx/api/views.py | 30 +++++ awx/main/models/jobs.py | 17 ++- awx/main/tests/factories/fixtures.py | 10 +- awx/main/tests/functional/api/test_job.py | 88 ++++++++++++ .../tests/functional/api/test_job_template.py | 127 ++++++++++++++++++ awx/main/tests/functional/conftest.py | 6 + 8 files changed, 349 insertions(+), 7 deletions(-) create mode 100644 awx/main/tests/functional/api/test_job.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 612efb075b..5f0f3010b3 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -86,6 +86,7 @@ SUMMARIZABLE_FK_FIELDS = { 'scm_project': DEFAULT_SUMMARY_FIELDS + ('status', 'scm_type'), 'project_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed',), 'credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud'), + 'vault_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud'), 'job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'elapsed'), 'job_template': DEFAULT_SUMMARY_FIELDS, 'workflow_job_template': DEFAULT_SUMMARY_FIELDS, @@ -2090,14 +2091,42 @@ class LabelsListMixin(object): return res +# TODO: remove when API v1 is removed +@six.add_metaclass(BaseSerializerMetaclass) +class V1JobOptionsSerializer(BaseSerializer): + + class Meta: + model = Credential + fields = ('*', 'cloud_credential', 'network_credential') + + V1_FIELDS = { + 'cloud_credential': models.PositiveIntegerField(blank=True, null=True, default=None), + 'network_credential': models.PositiveIntegerField(blank=True, null=True, default=None) + } + + def build_field(self, field_name, info, model_class, nested_depth): + if field_name in self.V1_FIELDS: + return self.build_standard_field(field_name, + self.V1_FIELDS[field_name]) + return super(V1JobOptionsSerializer, self).build_field(field_name, info, model_class, nested_depth) + + class JobOptionsSerializer(LabelsListMixin, BaseSerializer): class Meta: fields = ('*', 'job_type', 'inventory', 'project', 'playbook', - 'credential', 'forks', 'limit', + 'credential', 'vault_credential', 'forks', 'limit', 'verbosity', 'extra_vars', 'job_tags', 'force_handlers', 'skip_tags', 'start_at_task', 'timeout', 'store_facts',) + def get_fields(self): + fields = super(JobOptionsSerializer, self).get_fields() + + # TODO: remove when API v1 is removed + if self.version == 1: + fields.update(V1JobOptionsSerializer().get_fields()) + return fields + def get_related(self, obj): res = super(JobOptionsSerializer, self).get_related(obj) res['labels'] = self.reverse('api:job_template_label_list', kwargs={'pk': obj.pk}) @@ -2107,7 +2136,19 @@ class JobOptionsSerializer(LabelsListMixin, BaseSerializer): res['project'] = self.reverse('api:project_detail', kwargs={'pk': obj.project.pk}) if obj.credential: res['credential'] = self.reverse('api:credential_detail', kwargs={'pk': obj.credential.pk}) - # TODO: add related links for `extra_credentials` + if obj.vault_credential: + res['vault_credential'] = self.reverse('api:credential_detail', kwargs={'pk': obj.vault_credential.pk}) + if self.version > 1: + view = 'api:%s_extra_credentials_list' % camelcase_to_underscore(obj.__class__.__name__) + res['extra_credentials'] = self.reverse(view, kwargs={'pk': obj.pk}) + else: + cloud_cred = obj.cloud_credential + if cloud_cred: + res['cloud_credential'] = self.reverse('api:credential_detail', kwargs={'pk': cloud_cred}) + net_cred = obj.network_credential + if net_cred: + res['cloud_credential'] = self.reverse('api:credential_detail', kwargs={'pk': net_cred}) + return res def to_representation(self, obj): @@ -2122,9 +2163,38 @@ class JobOptionsSerializer(LabelsListMixin, BaseSerializer): ret['playbook'] = '' if 'credential' in ret and not obj.credential: ret['credential'] = None + if 'vault_credential' in ret and not obj.vault_credential: + ret['vault_credential'] = None + if self.version == 1: + ret['cloud_credential'] = obj.cloud_credential + ret['network_credential'] = obj.network_credential return ret def validate(self, attrs): + if self.version == 1: # TODO: remove in 3.3 + if 'cloud_credential' in attrs: + pk = attrs.pop('cloud_credential') + for cred in self.instance.cloud_credentials: + self.instance.extra_credentials.remove(cred) + if pk: + cred = Credential.objects.get(pk=pk) + if cred.credential_type.kind != 'cloud': + raise serializers.ValidationError({ + 'cloud_credential': _('You must provide a cloud credential.'), + }) + self.instance.extra_credentials.add(cred) + if 'network_credential' in attrs: + pk = attrs.pop('network_credential') + for cred in self.instance.network_credentials: + self.instance.extra_credentials.remove(cred) + if pk: + cred = Credential.objects.get(pk=pk) + if cred.credential_type.kind != 'net': + raise serializers.ValidationError({ + 'network_credential': _('You must provide a network credential.'), + }) + self.instance.extra_credentials.add(cred) + if 'project' in self.fields and 'playbook' in self.fields: project = attrs.get('project', self.instance and self.instance.project or None) playbook = attrs.get('playbook', self.instance and self.instance.playbook or '') diff --git a/awx/api/urls.py b/awx/api/urls.py index eb7e331cee..d5e102082e 100644 --- a/awx/api/urls.py +++ b/awx/api/urls.py @@ -385,7 +385,9 @@ v1_urls = patterns('awx.api.views', v2_urls = patterns('awx.api.views', url(r'^$', 'api_v2_root_view'), url(r'^credential_types/', include(credential_type_urls)), - url(r'^hosts/(?P[0-9]+)/ansible_facts/$', 'host_ansible_facts_detail'), + url(r'^hosts/(?P[0-9]+)/ansible_facts/$', 'host_ansible_facts_detail'), + url(r'^jobs/(?P[0-9]+)/extra_credentials/$', 'job_extra_credentials_list'), + url(r'^job_templates/(?P[0-9]+)/extra_credentials/$', 'job_template_extra_credentials_list'), ) urlpatterns = patterns('awx.api.views', diff --git a/awx/api/views.py b/awx/api/views.py index 89b983426e..9785e2d655 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -2659,6 +2659,21 @@ class JobTemplateNotificationTemplatesSuccessList(SubListCreateAttachDetachAPIVi new_in_300 = True +class JobTemplateExtraCredentialsList(SubListCreateAttachDetachAPIView): + + model = Credential + serializer_class = CredentialSerializer + parent_model = JobTemplate + relationship = 'extra_credentials' + new_in_320 = True + new_in_api_v2 = True + + def is_valid_relation(self, parent, sub, created=False): + if sub.credential_type.kind not in ('net', 'cloud'): + return {'error': _('Extra credentials must be network or cloud.')} + return super(JobTemplateExtraCredentialsList, self).is_valid_relation(parent, sub, created) + + class JobTemplateLabelList(DeleteLastUnattachLabelMixin, SubListCreateAttachDetachAPIView): model = Label @@ -3420,6 +3435,21 @@ class JobDetail(RetrieveUpdateDestroyAPIView): return super(JobDetail, self).destroy(request, *args, **kwargs) +class JobExtraCredentialsList(SubListCreateAttachDetachAPIView): + + model = Credential + serializer_class = CredentialSerializer + parent_model = Job + relationship = 'extra_credentials' + new_in_320 = True + new_in_api_v2 = True + + def is_valid_relation(self, parent, sub, created=False): + if sub.credential_type.kind not in ('net', 'cloud'): + return {'error': _('Extra credentials must be network or cloud.')} + return super(JobExtraCredentialsList, self).is_valid_relation(parent, sub, created) + + class JobLabelList(SubListAPIView): model = Label diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index d8f1427109..40f4c22e9a 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -21,7 +21,6 @@ from django.core.exceptions import ValidationError # AWX from awx.api.versioning import reverse -from awx.main.constants import CLOUD_PROVIDERS from awx.main.models.base import * # noqa from awx.main.models.unified_jobs import * # noqa from awx.main.models.notifications import ( @@ -197,6 +196,22 @@ class JobOptions(BaseModel): def cloud_credentials(self): return [cred for cred in self.extra_credentials.all() if cred.credential_type.kind == 'cloud'] + # TODO: remove when API v1 is removed + @property + def cloud_credential(self): + try: + return self.cloud_credentials[-1].pk + except IndexError: + return None + + # TODO: remove when API v1 is removed + @property + def network_credential(self): + try: + return self.network_credentials[-1].pk + except IndexError: + return None + @property def passwords_needed_to_start(self): '''Return list of password field names needed to start the job.''' diff --git a/awx/main/tests/factories/fixtures.py b/awx/main/tests/factories/fixtures.py index 4ec373191f..2f04b70ad4 100644 --- a/awx/main/tests/factories/fixtures.py +++ b/awx/main/tests/factories/fixtures.py @@ -153,9 +153,6 @@ def mk_job_template(name, job_type='run', if jt.credential is None: jt.ask_credential_on_launch = True - jt.network_credential = network_credential - jt.cloud_credential = cloud_credential - jt.project = project jt.survey_spec = spec @@ -164,6 +161,13 @@ def mk_job_template(name, job_type='run', if persisted: jt.save() + if cloud_credential: + cloud_credential.save() + jt.extra_credentials.add(cloud_credential) + if network_credential: + network_credential.save() + jt.extra_credentials.add(network_credential) + jt.save() return jt diff --git a/awx/main/tests/functional/api/test_job.py b/awx/main/tests/functional/api/test_job.py new file mode 100644 index 0000000000..0ca4fcf13b --- /dev/null +++ b/awx/main/tests/functional/api/test_job.py @@ -0,0 +1,88 @@ +import pytest + +from awx.api.versioning import reverse + + +# TODO: test this with RBAC and lower-priveleged users +@pytest.mark.django_db +def test_extra_credential_creation(get, post, organization_factory, job_template_factory, credentialtype_aws): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + job = jt.create_unified_job() + + url = reverse('api:job_extra_credentials_list', kwargs={'version': 'v2', 'pk': job.pk}) + response = post(url, { + 'name': 'My Cred', + 'credential_type': credentialtype_aws.pk, + 'inputs': { + 'username': 'bob', + 'password': 'secret', + } + }, objs.superusers.admin) + assert response.status_code == 201 + + response = get(url, user=objs.superusers.admin) + assert response.data.get('count') == 1 + + +# TODO: test this with RBAC and lower-priveleged users +@pytest.mark.django_db +def test_attach_extra_credential(get, post, organization_factory, job_template_factory, credential): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + job = jt.create_unified_job() + + url = reverse('api:job_extra_credentials_list', kwargs={'version': 'v2', 'pk': job.pk}) + response = get(url, user=objs.superusers.admin) + assert response.data.get('count') == 0 + + response = post(url, { + 'associate': True, + 'id': credential.id, + }, objs.superusers.admin) + assert response.status_code == 204 + + response = get(url, user=objs.superusers.admin) + assert response.data.get('count') == 1 + + +# TODO: test this with RBAC and lower-priveleged users +@pytest.mark.django_db +def test_detach_extra_credential(get, post, organization_factory, job_template_factory, credential): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + jt.extra_credentials.add(credential) + jt.save() + job = jt.create_unified_job() + + url = reverse('api:job_extra_credentials_list', kwargs={'version': 'v2', 'pk': job.pk}) + response = get(url, user=objs.superusers.admin) + assert response.data.get('count') == 1 + + response = post(url, { + 'disassociate': True, + 'id': credential.id, + }, objs.superusers.admin) + assert response.status_code == 204 + + response = get(url, user=objs.superusers.admin) + assert response.data.get('count') == 0 + + +@pytest.mark.django_db +def test_attach_extra_credential_wrong_kind_xfail(get, post, organization_factory, job_template_factory, machine_credential): + """Extra credentials only allow net + cloud credentials""" + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + job = jt.create_unified_job() + + url = reverse('api:job_extra_credentials_list', kwargs={'version': 'v2', 'pk': job.pk}) + response = post(url, { + 'associate': True, + 'id': machine_credential.id, + }, objs.superusers.admin) + assert response.status_code == 400 diff --git a/awx/main/tests/functional/api/test_job_template.py b/awx/main/tests/functional/api/test_job_template.py index 09e8be11dd..5cd43bb4b0 100644 --- a/awx/main/tests/functional/api/test_job_template.py +++ b/awx/main/tests/functional/api/test_job_template.py @@ -36,6 +36,133 @@ def test_create(post, project, machine_credential, inventory, alice, grant_proje }, alice, expect=expect) +# TODO: test this with RBAC and lower-priveleged users +@pytest.mark.django_db +def test_extra_credential_creation(get, post, organization_factory, job_template_factory, credentialtype_aws): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + + url = reverse('api:job_template_extra_credentials_list', kwargs={'version': 'v2', 'pk': jt.pk}) + response = post(url, { + 'name': 'My Cred', + 'credential_type': credentialtype_aws.pk, + 'inputs': { + 'username': 'bob', + 'password': 'secret', + } + }, objs.superusers.admin) + assert response.status_code == 201 + + response = get(url, user=objs.superusers.admin) + assert response.data.get('count') == 1 + + +# TODO: test this with RBAC and lower-priveleged users +@pytest.mark.django_db +def test_attach_extra_credential(get, post, organization_factory, job_template_factory, credential): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + + url = reverse('api:job_template_extra_credentials_list', kwargs={'version': 'v2', 'pk': jt.pk}) + response = post(url, { + 'associate': True, + 'id': credential.id, + }, objs.superusers.admin) + assert response.status_code == 204 + + response = get(url, user=objs.superusers.admin) + assert response.data.get('count') == 1 + + +# TODO: test this with RBAC and lower-priveleged users +@pytest.mark.django_db +def test_detach_extra_credential(get, post, organization_factory, job_template_factory, credential): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + jt.extra_credentials.add(credential) + jt.save() + + url = reverse('api:job_template_extra_credentials_list', kwargs={'version': 'v2', 'pk': jt.pk}) + response = post(url, { + 'disassociate': True, + 'id': credential.id, + }, objs.superusers.admin) + assert response.status_code == 204 + + response = get(url, user=objs.superusers.admin) + assert response.data.get('count') == 0 + + +@pytest.mark.django_db +def test_attach_extra_credential_wrong_kind_xfail(get, post, organization_factory, job_template_factory, machine_credential): + """Extra credentials only allow net + cloud credentials""" + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + + url = reverse('api:job_template_extra_credentials_list', kwargs={'version': 'v2', 'pk': jt.pk}) + response = post(url, { + 'associate': True, + 'id': machine_credential.id, + }, objs.superusers.admin) + assert response.status_code == 400 + + response = get(url, user=objs.superusers.admin) + assert response.data.get('count') == 0 + + +@pytest.mark.django_db +def test_v1_extra_credentials_detail(get, organization_factory, job_template_factory, credential, net_credential): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + jt.extra_credentials.add(credential) + jt.extra_credentials.add(net_credential) + jt.save() + + url = reverse('api:job_template_detail', kwargs={'version': 'v1', 'pk': jt.pk}) + response = get(url, user=objs.superusers.admin) + assert response.data.get('cloud_credential') == credential.pk + assert response.data.get('network_credential') == net_credential.pk + + +@pytest.mark.django_db +def test_v1_set_extra_credentials(get, patch, organization_factory, job_template_factory, credential, net_credential): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + jt.save() + + url = reverse('api:job_template_detail', kwargs={'version': 'v1', 'pk': jt.pk}) + response = patch(url, { + 'cloud_credential': credential.pk, + 'network_credential': net_credential.pk + }, objs.superusers.admin) + assert response.status_code == 200 + + url = reverse('api:job_template_detail', kwargs={'version': 'v1', 'pk': jt.pk}) + response = get(url, user=objs.superusers.admin) + assert response.status_code == 200 + assert response.data.get('cloud_credential') == credential.pk + assert response.data.get('network_credential') == net_credential.pk + + url = reverse('api:job_template_detail', kwargs={'version': 'v1', 'pk': jt.pk}) + response = patch(url, { + 'cloud_credential': None, + 'network_credential': None, + }, objs.superusers.admin) + assert response.status_code == 200 + + url = reverse('api:job_template_detail', kwargs={'version': 'v1', 'pk': jt.pk}) + response = get(url, user=objs.superusers.admin) + assert response.status_code == 200 + assert response.data.get('cloud_credential') is None + assert response.data.get('network_credential') is None + + @pytest.mark.django_db @pytest.mark.parametrize( "grant_project, grant_credential, grant_inventory, expect", [ diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 1d976ca492..f4be836d4f 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -215,6 +215,12 @@ def credential(credentialtype_aws): inputs={'username': 'something', 'password': 'secret'}) +@pytest.fixture +def net_credential(credentialtype_net): + return Credential.objects.create(credential_type=credentialtype_net, name='test-cred', + inputs={'username': 'something', 'password': 'secret'}) + + @pytest.fixture def machine_credential(credentialtype_ssh): return Credential.objects.create(credential_type=credentialtype_ssh, name='machine-cred',