From 44fa3b18a9b500fb293324e375a6cc4d0fe46915 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Fri, 28 Sep 2018 13:58:22 -0400 Subject: [PATCH] Adjust prompt logic and views to accept workflow inventory --- awx/api/serializers.py | 16 +++- awx/api/views/__init__.py | 8 +- .../0050_v340_workflow_inventory.py | 9 +- awx/main/models/jobs.py | 75 +++++++++++------ awx/main/models/workflow.py | 84 ++++++++++++------- 5 files changed, 130 insertions(+), 62 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index b7e5e1a62c..508b04c244 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4421,11 +4421,15 @@ class WorkflowJobLaunchSerializer(BaseSerializer): variables_needed_to_start = serializers.ReadOnlyField() survey_enabled = serializers.SerializerMethodField() extra_vars = VerbatimField(required=False, write_only=True) + inventory = serializers.PrimaryKeyRelatedField( + queryset=Inventory.objects.all(), + required=False, write_only=True + ) workflow_job_template_data = serializers.SerializerMethodField() class Meta: model = WorkflowJobTemplate - fields = ('can_start_without_user_input', 'extra_vars', + fields = ('can_start_without_user_input', 'extra_vars', 'inventory', 'survey_enabled', 'variables_needed_to_start', 'node_templates_missing', 'node_prompts_rejected', 'workflow_job_template_data') @@ -4444,11 +4448,17 @@ class WorkflowJobLaunchSerializer(BaseSerializer): accepted, rejected, errors = obj._accept_or_ignore_job_kwargs( _exclude_errors=['required'], **attrs) + self._ignored_fields = rejected + + if errors: + raise serializers.ValidationError(errors) WFJT_extra_vars = obj.extra_vars - attrs = super(WorkflowJobLaunchSerializer, self).validate(attrs) + WFJT_inventory = obj.inventory + super(WorkflowJobLaunchSerializer, self).validate(attrs) obj.extra_vars = WFJT_extra_vars - return attrs + obj.inventory = WFJT_inventory + return accepted class NotificationTemplateSerializer(BaseSerializer): diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 84b2ebc151..ca9a615a9c 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3106,6 +3106,8 @@ class WorkflowJobTemplateLaunch(WorkflowsEnforcementMixin, RetrieveAPIView): extra_vars.setdefault(v, u'') if extra_vars: data['extra_vars'] = extra_vars + if obj.ask_inventory_on_launch: + data['inventory'] = obj.inventory_id return data def post(self, request, *args, **kwargs): @@ -3115,14 +3117,12 @@ class WorkflowJobTemplateLaunch(WorkflowsEnforcementMixin, RetrieveAPIView): if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - prompted_fields, ignored_fields, errors = obj._accept_or_ignore_job_kwargs(**request.data) - - new_job = obj.create_unified_job(**prompted_fields) + new_job = obj.create_unified_job(**serializer.validated_data) new_job.signal_start() data = OrderedDict() data['workflow_job'] = new_job.id - data['ignored_fields'] = ignored_fields + data['ignored_fields'] = serializer._ignored_fields data.update(WorkflowJobSerializer(new_job, context=self.get_serializer_context()).to_representation(new_job)) headers = {'Location': new_job.get_absolute_url(request)} return Response(data, status=status.HTTP_201_CREATED, headers=headers) diff --git a/awx/main/migrations/0050_v340_workflow_inventory.py b/awx/main/migrations/0050_v340_workflow_inventory.py index 9d5e6a2cd5..8481c6c2d6 100644 --- a/awx/main/migrations/0050_v340_workflow_inventory.py +++ b/awx/main/migrations/0050_v340_workflow_inventory.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.11 on 2018-09-27 18:47 +# Generated by Django 1.11.11 on 2018-09-27 19:50 from __future__ import unicode_literals import awx.main.fields @@ -14,10 +14,15 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AddField( + model_name='workflowjob', + name='char_prompts', + field=awx.main.fields.JSONField(blank=True, default={}), + ), migrations.AddField( model_name='workflowjob', name='inventory', - field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied to all job templates in workflow that prompt for inventory.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workflowjobs', to='main.Inventory'), + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workflowjobs', to='main.Inventory'), ), migrations.AddField( model_name='workflowjobtemplate', diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index e774469216..4e5441b729 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -894,19 +894,19 @@ class NullablePromptPsuedoField(object): instance.char_prompts[self.field_name] = value -class LaunchTimeConfig(BaseModel): +class LaunchTimeConfigBase(BaseModel): ''' - Common model for all objects that save details of a saved launch config - WFJT / WJ nodes, schedules, and job launch configs (not all implemented yet) + Needed as separate class from LaunchTimeConfig because some models + use `extra_data` and some use `extra_vars`. We cannot change the API, + so we force fake it in the model definitions + - model defines extra_vars - use this class + - model needs to use extra data - use LaunchTimeConfig + Use this for models which are SurveyMixins and UnifiedJobs or Templates ''' class Meta: abstract = True # Prompting-related fields that have to be handled as special cases - credentials = models.ManyToManyField( - 'Credential', - related_name='%(class)ss' - ) inventory = models.ForeignKey( 'Inventory', related_name='%(class)ss', @@ -915,15 +915,6 @@ class LaunchTimeConfig(BaseModel): default=None, on_delete=models.SET_NULL, ) - extra_data = JSONField( - blank=True, - default={} - ) - survey_passwords = prevent_search(JSONField( - blank=True, - default={}, - editable=False, - )) # All standard fields are stored in this dictionary field # This is a solution to the nullable CharField problem, specific to prompting char_prompts = JSONField( @@ -933,6 +924,7 @@ class LaunchTimeConfig(BaseModel): def prompts_dict(self, display=False): data = {} + # Some types may have different prompts, but always subset of JT prompts for prompt_name in JobTemplate.get_ask_mapping().keys(): try: field = self._meta.get_field(prompt_name) @@ -945,11 +937,11 @@ class LaunchTimeConfig(BaseModel): if len(prompt_val) > 0: data[prompt_name] = prompt_val elif prompt_name == 'extra_vars': - if self.extra_data: + if self.extra_vars: if display: - data[prompt_name] = self.display_extra_data() + data[prompt_name] = self.display_extra_vars() else: - data[prompt_name] = self.extra_data + data[prompt_name] = self.extra_vars if self.survey_passwords and not display: data['survey_passwords'] = self.survey_passwords else: @@ -958,18 +950,18 @@ class LaunchTimeConfig(BaseModel): data[prompt_name] = prompt_val return data - def display_extra_data(self): + def display_extra_vars(self): ''' Hides fields marked as passwords in survey. ''' if self.survey_passwords: - extra_data = parse_yaml_or_json(self.extra_data).copy() + extra_vars = parse_yaml_or_json(self.extra_vars).copy() for key, value in self.survey_passwords.items(): - if key in extra_data: - extra_data[key] = value - return extra_data + if key in extra_vars: + extra_vars[key] = value + return extra_vars else: - return self.extra_data + return self.extra_vars @property def _credential(self): @@ -993,6 +985,39 @@ class LaunchTimeConfig(BaseModel): return None +class LaunchTimeConfig(LaunchTimeConfigBase): + ''' + Common model for all objects that save details of a saved launch config + WFJT / WJ nodes, schedules, and job launch configs (not all implemented yet) + ''' + class Meta: + abstract = True + + # Special case prompting fields, even more special than the other ones + extra_data = JSONField( + blank=True, + default={} + ) + survey_passwords = prevent_search(JSONField( + blank=True, + default={}, + editable=False, + )) + # Credentials needed for non-unified job / unified JT models + credentials = models.ManyToManyField( + 'Credential', + related_name='%(class)ss' + ) + + @property + def extra_vars(self): + return self.extra_data + + @extra_vars.setter + def extra_vars(self, extra_vars): + self.extra_data = extra_vars + + for field_name in JobTemplate.get_ask_mapping().keys(): try: LaunchTimeConfig._meta.get_field(field_name) diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 70a736e07d..0f988afb79 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -31,7 +31,7 @@ from awx.main.models.mixins import ( SurveyJobMixin, RelatedJobsMixin, ) -from awx.main.models.jobs import LaunchTimeConfig, JobTemplate +from awx.main.models.jobs import LaunchTimeConfigBase, LaunchTimeConfig, JobTemplate from awx.main.models.credential import Credential from awx.main.redact import REPLACE_STR from awx.main.fields import JSONField @@ -188,6 +188,16 @@ class WorkflowJobNode(WorkflowNodeBase): def get_absolute_url(self, request=None): return reverse('api:workflow_job_node_detail', kwargs={'pk': self.pk}, request=request) + def prompts_dict(self, *args, **kwargs): + r = super(WorkflowJobNode, self).prompts_dict(*args, **kwargs) + # Explination - WFJT extra_vars still break pattern, so they are not + # put through prompts processing, but inventory is only accepted + # if JT prompts for it, so it goes through this mechanism + if self.workflow_job and self.workflow_job.inventory_id: + # workflow job inventory takes precedence + r['inventory'] = self.workflow_job.inventory + return r + def get_job_kwargs(self): ''' In advance of creating a new unified job as part of a workflow, @@ -280,15 +290,6 @@ class WorkflowJobOptions(BaseModel): allow_simultaneous = models.BooleanField( default=False ) - inventory = models.ForeignKey( - 'Inventory', - related_name='%(class)ss', - blank=True, - null=True, - default=None, - on_delete=models.SET_NULL, - help_text=_('Inventory applied to all job templates in workflow that prompt for inventory.'), - ) extra_vars_dict = VarsDictProperty('extra_vars', True) @@ -299,7 +300,8 @@ class WorkflowJobOptions(BaseModel): @classmethod def _get_unified_job_field_names(cls): return set(f.name for f in WorkflowJobOptions._meta.fields) | set( - ['name', 'description', 'schedule', 'survey_passwords', 'labels'] + # NOTE: if other prompts are added to WFJT, put fields in WJOptions, remove inventory + ['name', 'description', 'schedule', 'survey_passwords', 'labels', 'inventory'] ) def _create_workflow_nodes(self, old_node_list, user=None): @@ -351,6 +353,15 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl on_delete=models.SET_NULL, related_name='workflows', ) + inventory = models.ForeignKey( + 'Inventory', + related_name='%(class)ss', + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + help_text=_('Inventory applied to all job templates in workflow that prompt for inventory.'), + ) ask_inventory_on_launch = AskForField( blank=True, default=False, @@ -413,23 +424,40 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl exclude_errors = kwargs.pop('_exclude_errors', []) prompted_data = {} rejected_data = {} - accepted_vars, rejected_vars, errors_dict = self.accept_or_ignore_variables( - kwargs.get('extra_vars', {}), - _exclude_errors=exclude_errors, - extra_passwords=kwargs.get('survey_passwords', {})) - if accepted_vars: - prompted_data['extra_vars'] = accepted_vars - if rejected_vars: - rejected_data['extra_vars'] = rejected_vars + errors_dict = {} - # WFJTs do not behave like JTs, it can not accept inventory, credential, etc. - bad_kwargs = kwargs.copy() - bad_kwargs.pop('extra_vars', None) - bad_kwargs.pop('survey_passwords', None) - if bad_kwargs: - rejected_data.update(bad_kwargs) - for field in bad_kwargs: - errors_dict[field] = _('Field is not allowed for use in workflows.') + # Handle all the fields that have prompting rules + # NOTE: If WFJTs prompt for other things, this logic can be combined with jobs + for field_name, ask_field_name in self.get_ask_mapping().items(): + + if field_name == 'extra_vars': + accepted_vars, rejected_vars, vars_errors = self.accept_or_ignore_variables( + kwargs.get('extra_vars', {}), + _exclude_errors=exclude_errors, + extra_passwords=kwargs.get('survey_passwords', {})) + if accepted_vars: + prompted_data['extra_vars'] = accepted_vars + if rejected_vars: + rejected_data['extra_vars'] = rejected_vars + errors_dict.update(vars_errors) + + if field_name not in kwargs: + continue + new_value = kwargs[field_name] + old_value = getattr(self, field_name) + + if new_value == old_value: + continue # no-op case: Counted as neither accepted or ignored + elif getattr(self, ask_field_name): + # accepted prompt + prompted_data[field_name] = new_value + else: + # unprompted - template is not configured to accept field on launch + rejected_data[field_name] = new_value + # Not considered an error for manual launch, to support old + # behavior of putting them in ignored_fields and launching anyway + if 'prompts' not in exclude_errors: + errors_dict[field_name] = _('Field is not configured to prompt on launch.').format(field_name=field_name) return prompted_data, rejected_data, errors_dict @@ -459,7 +487,7 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl return WorkflowJob.objects.filter(workflow_job_template=self) -class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin): +class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin, LaunchTimeConfigBase): class Meta: app_label = 'main' ordering = ('id',)