diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 65b573de11..d8fcb84b93 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1326,7 +1326,7 @@ class JobOptionsSerializer(BaseSerializer): fields = ('*', 'job_type', 'inventory', 'project', 'playbook', 'credential', 'cloud_credential', 'forks', 'limit', 'verbosity', 'extra_vars', 'job_tags', 'force_handlers', - 'skip_tags', 'start_at_task') + 'skip_tags', 'start_at_task',) def get_related(self, obj): res = super(JobOptionsSerializer, self).get_related(obj) @@ -1379,7 +1379,7 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer): class Meta: model = JobTemplate - fields = ('*', 'host_config_key', 'ask_variables_on_launch', 'survey_enabled', 'become_enabled') + fields = ('*', 'host_config_key', 'ask_variables_on_launch', 'survey_enabled', 'become_enabled',) def get_related(self, obj): res = super(JobTemplateSerializer, self).get_related(obj) @@ -1418,12 +1418,11 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer): class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer): - passwords_needed_to_start = serializers.Field(source='passwords_needed_to_start') ask_variables_on_launch = serializers.Field(source='ask_variables_on_launch') class Meta: model = Job - fields = ('*', 'job_template', 'passwords_needed_to_start', 'ask_variables_on_launch') + fields = ('*', 'job_template', 'ask_variables_on_launch') def get_related(self, obj): res = super(JobSerializer, self).get_related(obj) @@ -1494,6 +1493,41 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer): pass return ret +''' +This Serializer requires that model be set to something with a 'passwords_needed_to_start' +variable. +Inheriting classes should add 'passwords_needed_to_start' to their list of fields +to take advantage of the validate functionality. +''' +class PasswordsNeededToStartMixin(BaseSerializer): + + passwords_needed_to_start = serializers.Field(source='passwords_needed_to_start') + + def validate_passwords_needed_to_start(self, attrs, source): + obj = self.context.get('obj') + passwords = {} + + credential = attrs.get('credential', None) or obj.credential + # fill passwords dict with request data passwords + if credential and credential.passwords_needed: + try: + for p in credential.passwords_needed: + passwords[p] = self.init_data[p] + except KeyError: + raise serializers.ValidationError(credential.passwords_needed) + + # Use the context dict to allow the view to access the passwords + self.context['passwords'] = passwords + return attrs + + +class JobTemplateSerializerWithPasswordsNeededToStart(JobTemplateSerializer, PasswordsNeededToStartMixin): + pass + + +class JobSerializerWithPasswordsNeededToStart(JobSerializer, PasswordsNeededToStartMixin): + pass + class JobCancelSerializer(JobSerializer): @@ -1503,8 +1537,80 @@ class JobCancelSerializer(JobSerializer): fields = ('can_cancel',) -class JobRelaunchSerializer(JobSerializer): - passwords_needed_to_start = serializers.SerializerMethodField('get_passwords_needed_to_start') +class JobLaunchSerializer(JobTemplateSerializerWithPasswordsNeededToStart): + + can_start_without_user_input = serializers.Field(source='can_start_without_user_input') + variables_needed_to_start = serializers.Field(source='variables_needed_to_start') + credential_needed_to_start = serializers.SerializerMethodField('get_credential_needed_to_start') + survey_enabled = serializers.SerializerMethodField('get_survey_enabled') + + class Meta: + fields = ('can_start_without_user_input', 'passwords_needed_to_start', 'extra_vars', + 'ask_variables_on_launch', 'survey_enabled', 'variables_needed_to_start', + 'credential', 'credential_needed_to_start',) + write_only_fields = ('credential','extra_vars',) + + def to_native(self, obj): + res = super(JobLaunchSerializer, self).to_native(obj) + view = self.context.get('view', None) + if obj and hasattr(view, '_raw_data_form_marker'): + if obj.passwords_needed_to_start: + password_keys = dict([(p, u'') for p in obj.passwords_needed_to_start]) + res.update(password_keys) + if self.get_credential_needed_to_start(obj) is True: + res.update(dict(credential='')) + return res + + def get_credential_needed_to_start(self, obj): + return not (obj and obj.credential and obj.credential.active) + + def get_survey_enabled(self, obj): + if obj: + return obj.survey_enabled and 'spec' in obj.survey_spec + return False + + def validate_credential(self, attrs, source): + obj = self.context.get('obj') + credential = attrs.get(source, None) or (obj and obj.credential) + if not credential or not credential.active: + raise serializers.ValidationError('Credential not provided') + attrs[source] = credential + return attrs + + def validate(self, attrs): + obj = self.context.get('obj') + extra_vars = attrs.get('extra_vars', {}) + try: + extra_vars = literal_eval(extra_vars) + extra_vars = json.dumps(extra_vars) + except Exception: + pass + + try: + extra_vars = json.loads(extra_vars) + except (ValueError, TypeError): + try: + extra_vars = yaml.safe_load(extra_vars) + except (yaml.YAMLError, TypeError, AttributeError): + raise serializers.ValidationError(dict(extra_vars=['Must be valid JSON or YAML'])) + + if not isinstance(extra_vars, dict): + extra_vars = {} + + if self.get_survey_enabled(obj): + validation_errors = obj.survey_variable_validation(extra_vars) + if validation_errors: + raise serializers.ValidationError(dict(variables_needed_to_start=validation_errors)) + + if obj.job_type != PERM_INVENTORY_SCAN and (obj.project is None or not obj.project.active): + raise serializers.ValidationError(dict(errors=["Job Template Project is missing or undefined"])) + if obj.inventory is None or not obj.inventory.active: + raise serializers.ValidationError(dict(errors=["Job Template Inventory is missing or undefined"])) + + return attrs + + +class JobRelaunchSerializer(JobSerializerWithPasswordsNeededToStart): class Meta: fields = ('passwords_needed_to_start',) @@ -1513,26 +1619,10 @@ class JobRelaunchSerializer(JobSerializer): res = super(JobRelaunchSerializer, self).to_native(obj) view = self.context.get('view', None) if hasattr(view, '_raw_data_form_marker'): - password_keys = dict([(p, u'') for p in self.get_passwords_needed_to_start(obj)]) + password_keys = dict([(p, u'') for p in obj.passwords_needed_to_start]) res.update(password_keys) return res - def get_passwords_needed_to_start(self, obj): - if obj: - return obj.passwords_needed_to_start - return '' - - def validate_passwords_needed_to_start(self, attrs, source): - obj = self.context.get('obj') - data = self.context.get('data') - - # Check for passwords needed - needed = self.get_passwords_needed_to_start(obj) - provided = dict([(field, data.get(field, '')) for field in needed]) - if not all(provided.values()): - raise serializers.ValidationError(needed) - return attrs - def validate(self, attrs): obj = self.context.get('obj') if not obj.credential or obj.credential.active is False: @@ -1735,95 +1825,6 @@ class AdHocCommandEventSerializer(BaseSerializer): res['host'] = reverse('api:host_detail', args=(obj.host.pk,)) return res -class JobLaunchSerializer(BaseSerializer): - passwords_needed_to_start = serializers.Field(source='passwords_needed_to_start') - can_start_without_user_input = serializers.Field(source='can_start_without_user_input') - variables_needed_to_start = serializers.Field(source='variables_needed_to_start') - credential_needed_to_start = serializers.SerializerMethodField('get_credential_needed_to_start') - survey_enabled = serializers.SerializerMethodField('get_survey_enabled') - - class Meta: - model = JobTemplate - fields = ('can_start_without_user_input', 'passwords_needed_to_start', 'extra_vars', - 'ask_variables_on_launch', 'survey_enabled', 'variables_needed_to_start', - 'credential', 'credential_needed_to_start',) - read_only_fields = ('ask_variables_on_launch',) - write_only_fields = ('credential','extra_vars',) - - def to_native(self, obj): - res = super(JobLaunchSerializer, self).to_native(obj) - view = self.context.get('view', None) - if obj and hasattr(view, '_raw_data_form_marker'): - if obj.passwords_needed_to_start: - password_keys = dict([(p, u'') for p in obj.passwords_needed_to_start]) - res.update(password_keys) - if self.get_credential_needed_to_start(obj) is True: - res.update(dict(credential='')) - return res - - def get_credential_needed_to_start(self, obj): - return not (obj and obj.credential and obj.credential.active) - - def get_survey_enabled(self, obj): - if obj: - return obj.survey_enabled and 'spec' in obj.survey_spec - return False - - def validate_credential(self, attrs, source): - obj = self.context.get('obj') - credential = attrs.get(source, None) or (obj and obj.credential) - if not credential or not credential.active: - raise serializers.ValidationError('Credential not provided') - attrs[source] = credential - return attrs - - def validate_passwords_needed_to_start(self, attrs, source): - obj = self.context.get('obj') - passwords = self.context.get('passwords') - data = self.context.get('data') - - credential = attrs.get('credential', None) or obj.credential - # fill passwords dict with request data passwords - if credential and credential.passwords_needed: - try: - for p in credential.passwords_needed: - passwords[p] = data[p] - except KeyError: - raise serializers.ValidationError(credential.passwords_needed) - return attrs - - def validate(self, attrs): - obj = self.context.get('obj') - extra_vars = attrs.get('extra_vars', {}) - try: - extra_vars = literal_eval(extra_vars) - extra_vars = json.dumps(extra_vars) - except Exception: - pass - - try: - extra_vars = json.loads(extra_vars) - except (ValueError, TypeError): - try: - extra_vars = yaml.safe_load(extra_vars) - except (yaml.YAMLError, TypeError, AttributeError): - raise serializers.ValidationError(dict(extra_vars=['Must be valid JSON or YAML'])) - - if not isinstance(extra_vars, dict): - extra_vars = {} - - if self.get_survey_enabled(obj): - validation_errors = obj.survey_variable_validation(extra_vars) - if validation_errors: - raise serializers.ValidationError(dict(variables_needed_to_start=validation_errors)) - - if obj.job_type != PERM_INVENTORY_SCAN and (obj.project is None or not obj.project.active): - raise serializers.ValidationError(dict(errors=["Job Template Project is missing or undefined"])) - if obj.inventory is None or not obj.inventory.active: - raise serializers.ValidationError(dict(errors=["Job Template Inventory is missing or undefined"])) - - return attrs - class ScheduleSerializer(BaseSerializer): class Meta: diff --git a/awx/api/views.py b/awx/api/views.py index e52dd77009..4bce22b70f 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -1448,8 +1448,7 @@ class JobTemplateLaunch(RetrieveAPIView, GenericAPIView): if 'credential' not in request.DATA and 'credential_id' in request.DATA: request.DATA['credential'] = request.DATA['credential_id'] - passwords = {} - serializer = self.serializer_class(data=request.DATA, context={'obj': obj, 'data': request.DATA, 'passwords': passwords}) + serializer = self.serializer_class(data=request.DATA, context={'obj': obj}) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1458,7 +1457,7 @@ class JobTemplateLaunch(RetrieveAPIView, GenericAPIView): } if 'extra_vars' in request.DATA: kv['extra_vars'] = request.DATA['extra_vars'] - kv.update(passwords) + kv.update(serializer.context['passwords']) new_job = obj.create_unified_job(**kv) result = new_job.signal_start(**kv) diff --git a/awx/main/tests/jobs/__init__.py b/awx/main/tests/jobs/__init__.py index bf5eedafe7..092826ccf0 100644 --- a/awx/main/tests/jobs/__init__.py +++ b/awx/main/tests/jobs/__init__.py @@ -5,6 +5,7 @@ from __future__ import absolute_import from .jobs_monolithic import * # noqa from .job_launch import * # noqa +from .job_relaunch import * # noqa from .survey_password import * # noqa from .start_cancel import * # noqa from .base import * # noqa diff --git a/awx/main/tests/jobs/job_launch.py b/awx/main/tests/jobs/job_launch.py index 26124b1f61..7d078e88bb 100644 --- a/awx/main/tests/jobs/job_launch.py +++ b/awx/main/tests/jobs/job_launch.py @@ -148,6 +148,18 @@ class JobTemplateLaunchTest(BaseJobTestMixin, django.test.TestCase): with self.current_user(self.user_sue): self.post(self.launch_url, {}, expect=400) + # pass in a credential NOT viewable by the current logged in user + def test_explicit_credential_permission_denied(self): + #self.cred_sue.mark_inactive() + with self.current_user(self.user_doug): + self.post(self.launch_url, {'credential': self.cred_sue.pk}, expect=403) + + def test_explicit_deleted_credential(self): + self.cred_sue.mark_inactive() + with self.current_user(self.user_alex): + self.post(self.launch_url, {'credential': self.cred_sue.pk}, expect=400) + + class JobTemplateLaunchPasswordsTest(BaseJobTestMixin, django.test.TestCase): def setUp(self): super(JobTemplateLaunchPasswordsTest, self).setUp() diff --git a/awx/main/tests/jobs/job_relaunch.py b/awx/main/tests/jobs/job_relaunch.py new file mode 100644 index 0000000000..3068b8ee55 --- /dev/null +++ b/awx/main/tests/jobs/job_relaunch.py @@ -0,0 +1,61 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved + +# Python +from __future__ import absolute_import + +# Django +import django +from django.core.urlresolvers import reverse + +# AWX +from awx.main.models import * # noqa +from .base import BaseJobTestMixin + +__all__ = ['JobRelaunchTest',] + +class JobRelaunchTest(BaseJobTestMixin, django.test.TestCase): + def setUp(self): + super(JobRelaunchTest, self).setUp() + + self.url = reverse('api:job_template_list') + self.data = dict( + name = 'launched job template', + job_type = PERM_INVENTORY_DEPLOY, + inventory = self.inv_eng.pk, + project = self.proj_dev.pk, + credential = self.cred_sue.pk, + playbook = self.proj_dev.playbooks[0], + ) + + with self.current_user(self.user_sue): + response = self.post(self.url, self.data, expect=201) + self.launch_url = reverse('api:job_template_launch', + args=(response['id'],)) + response = self.post(self.launch_url, {}, expect=202) + self.relaunch_url = reverse('api:job_relaunch', + args=(response['job'],)) + + def test_relaunch_job(self): + with self.current_user(self.user_sue): + self.post(self.relaunch_url, {}, expect=201) + + def test_relaunch_inactive_project(self): + self.proj_dev.mark_inactive() + with self.current_user(self.user_sue): + self.post(self.relaunch_url, {}, expect=400) + + def test_relaunch_inactive_inventory(self): + self.inv_eng.mark_inactive() + with self.current_user(self.user_sue): + self.post(self.relaunch_url, {}, expect=400) + + def test_relaunch_deleted_inventory(self): + self.inv_eng.delete() + with self.current_user(self.user_sue): + self.post(self.relaunch_url, {}, expect=400) + + def test_relaunch_deleted_project(self): + self.proj_dev.delete() + with self.current_user(self.user_sue): + self.post(self.relaunch_url, {}, expect=400)