From 33328c4ad787571b782094ef35185ec9ba01e00f Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 27 Sep 2018 14:50:39 -0400 Subject: [PATCH 01/42] initial model changes for workflow inventory --- awx/api/serializers.py | 5 +-- .../0050_v340_workflow_inventory.py | 32 +++++++++++++++++++ awx/main/models/jobs.py | 3 +- awx/main/models/workflow.py | 15 ++++++++- 4 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 awx/main/migrations/0050_v340_workflow_inventory.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 531c6a869b..b7e5e1a62c 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3599,7 +3599,7 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo class Meta: model = WorkflowJobTemplate fields = ('*', 'extra_vars', 'organization', 'survey_enabled', 'allow_simultaneous', - 'ask_variables_on_launch',) + 'ask_variables_on_launch', 'inventory', 'ask_inventory_on_launch',) def get_related(self, obj): res = super(WorkflowJobTemplateSerializer, self).get_related(obj) @@ -3643,7 +3643,8 @@ class WorkflowJobSerializer(LabelsListMixin, UnifiedJobSerializer): model = WorkflowJob fields = ('*', 'workflow_job_template', 'extra_vars', 'allow_simultaneous', 'job_template', 'is_sliced_job', - '-execution_node', '-event_processing_finished', '-controller_node',) + '-execution_node', '-event_processing_finished', '-controller_node', + 'inventory',) def get_related(self, obj): res = super(WorkflowJobSerializer, self).get_related(obj) diff --git a/awx/main/migrations/0050_v340_workflow_inventory.py b/awx/main/migrations/0050_v340_workflow_inventory.py new file mode 100644 index 0000000000..9d5e6a2cd5 --- /dev/null +++ b/awx/main/migrations/0050_v340_workflow_inventory.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-09-27 18:47 +from __future__ import unicode_literals + +import awx.main.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0049_v330_validate_instance_capacity_adjustment'), + ] + + operations = [ + 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'), + ), + migrations.AddField( + model_name='workflowjobtemplate', + name='ask_inventory_on_launch', + field=awx.main.fields.AskForField(default=False), + ), + migrations.AddField( + model_name='workflowjobtemplate', + 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='workflowjobtemplates', to='main.Inventory'), + ), + ] diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index ae4e6f431b..e774469216 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -34,7 +34,7 @@ from awx.main.models.notifications import ( JobNotificationMixin, ) from awx.main.utils import parse_yaml_or_json, getattr_dne -from awx.main.fields import ImplicitRoleField +from awx.main.fields import ImplicitRoleField, JSONField, AskForField from awx.main.models.mixins import ( ResourceMixin, SurveyJobTemplateMixin, @@ -43,7 +43,6 @@ from awx.main.models.mixins import ( CustomVirtualEnvMixin, RelatedJobsMixin, ) -from awx.main.fields import JSONField, AskForField logger = logging.getLogger('awx.main.models.jobs') diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 1f1d776bd4..70a736e07d 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -24,7 +24,7 @@ from awx.main.models.rbac import ( ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ROLE_SINGLETON_SYSTEM_AUDITOR ) -from awx.main.fields import ImplicitRoleField +from awx.main.fields import ImplicitRoleField, AskForField from awx.main.models.mixins import ( ResourceMixin, SurveyJobTemplateMixin, @@ -280,6 +280,15 @@ 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) @@ -342,6 +351,10 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl on_delete=models.SET_NULL, related_name='workflows', ) + ask_inventory_on_launch = AskForField( + blank=True, + default=False, + ) admin_role = ImplicitRoleField(parent_role=[ 'singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, 'organization.workflow_admin_role' From 44fa3b18a9b500fb293324e375a6cc4d0fe46915 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Fri, 28 Sep 2018 13:58:22 -0400 Subject: [PATCH 02/42] 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',) From 0c52d17951a82afef3460e53a5c43c04f07e2714 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Fri, 28 Sep 2018 16:03:29 -0400 Subject: [PATCH 03/42] fix bug, handle RBAC, add test --- awx/api/serializers.py | 7 ++++- awx/api/views/__init__.py | 3 ++ awx/main/access.py | 28 +++++++++++++------ awx/main/models/jobs.py | 2 ++ .../tests/functional/test_rbac_workflow.py | 14 ++++++++++ .../tests/unit/models/test_workflow_unit.py | 2 +- 6 files changed, 45 insertions(+), 11 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 508b04c244..1836d67830 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3727,7 +3727,7 @@ class LaunchConfigurationBaseSerializer(BaseSerializer): if obj is None: return ret if 'extra_data' in ret and obj.survey_passwords: - ret['extra_data'] = obj.display_extra_data() + ret['extra_data'] = obj.display_extra_vars() return ret def get_summary_fields(self, obj): @@ -4450,6 +4450,11 @@ class WorkflowJobLaunchSerializer(BaseSerializer): **attrs) self._ignored_fields = rejected + if template.inventory and template.inventory.pending_deletion is True: + errors['inventory'] = _("The inventory associated with this Workflow is being deleted.") + elif 'inventory' in accepted and accepted['inventory'].pending_deletion: + errors['inventory'] = _("The provided inventory is being deleted.") + if errors: raise serializers.ValidationError(errors) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index ca9a615a9c..5233635197 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3117,6 +3117,9 @@ class WorkflowJobTemplateLaunch(WorkflowsEnforcementMixin, RetrieveAPIView): if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + if not request.user.can_access(JobLaunchConfig, 'add', serializer.validated_data, template=obj): + raise PermissionDenied() + new_job = obj.create_unified_job(**serializer.validated_data) new_job.signal_start() diff --git a/awx/main/access.py b/awx/main/access.py index 43d2ed08a0..8fa83ab084 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1949,19 +1949,29 @@ class WorkflowJobAccess(BaseAccess): if not template: return False - # If job was launched by another user, it could have survey passwords - if obj.created_by_id != self.user.pk: - # Obtain prompts used to start original job - JobLaunchConfig = obj._meta.get_field('launch_config').related_model - try: - config = JobLaunchConfig.objects.get(job=obj) - except JobLaunchConfig.DoesNotExist: - config = None + # Obtain prompts used to start original job + JobLaunchConfig = obj._meta.get_field('launch_config').related_model + try: + config = JobLaunchConfig.objects.get(job=obj) + except JobLaunchConfig.DoesNotExist: + if self.save_messages: + self.messages['detail'] = _('Workflow Job was launched with unknown prompts.') + return False - if config is None or config.prompts_dict(): + # Check if access to prompts to prevent relaunch + if config.prompts_dict(): + if obj.created_by_id != self.user.pk: if self.save_messages: self.messages['detail'] = _('Job was launched with prompts provided by another user.') return False + if not JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}): + if self.save_messages: + self.messages['detail'] = _('Job was launched with prompts you lack access to.') + return False + if config.has_unprompted(template): + if self.save_messages: + self.messages['detail'] = _('Job was launched with prompts no longer accepted.') + return False # execute permission to WFJT is mandatory for any relaunch return (self.user in template.execute_role) diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 4e5441b729..64d7811028 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -1019,6 +1019,8 @@ class LaunchTimeConfig(LaunchTimeConfigBase): for field_name in JobTemplate.get_ask_mapping().keys(): + if field_name == 'extra_vars': + continue try: LaunchTimeConfig._meta.get_field(field_name) except FieldDoesNotExist: diff --git a/awx/main/tests/functional/test_rbac_workflow.py b/awx/main/tests/functional/test_rbac_workflow.py index 116b9ec834..7a2fede786 100644 --- a/awx/main/tests/functional/test_rbac_workflow.py +++ b/awx/main/tests/functional/test_rbac_workflow.py @@ -149,6 +149,20 @@ class TestWorkflowJobAccess: wfjt.execute_role.members.add(alice) assert not WorkflowJobAccess(rando).can_start(workflow_job) + def test_relaunch_inventory_access(self, workflow_job, inventory, rando): + wfjt = workflow_job.workflow_job_template + wfjt.execute_role.members.add(rando) + assert rando in wfjt.execute_role + workflow_job.created_by = rando + workflow_job.inventory = inventory + workflow_job.save() + wfjt.ask_inventory_on_launch = True + wfjt.save() + JobLaunchConfig.objects.create(job=workflow_job, inventory=inventory) + assert not WorkflowJobAccess(rando).can_start(workflow_job) + inventory.use_role.members.add(rando) + assert WorkflowJobAccess(rando).can_start(workflow_job) + @pytest.mark.django_db class TestWFJTCopyAccess: diff --git a/awx/main/tests/unit/models/test_workflow_unit.py b/awx/main/tests/unit/models/test_workflow_unit.py index d3e0960507..7f427493e1 100644 --- a/awx/main/tests/unit/models/test_workflow_unit.py +++ b/awx/main/tests/unit/models/test_workflow_unit.py @@ -236,4 +236,4 @@ class TestWorkflowJobNodeJobKWARGS: def test_get_ask_mapping_integrity(): - assert WorkflowJobTemplate.get_ask_mapping().keys() == ['extra_vars'] + assert WorkflowJobTemplate.get_ask_mapping().keys() == ['extra_vars', 'inventory'] From 1203c8c0eee7f9436cdf3190aed1f4a5782c9205 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Fri, 28 Sep 2018 16:17:10 -0400 Subject: [PATCH 04/42] feature docs for workflow-level inventory --- docs/prompting.md | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/prompting.md b/docs/prompting.md index 2a2047839e..4715b8f65b 100644 --- a/docs/prompting.md +++ b/docs/prompting.md @@ -64,7 +64,7 @@ actions in the API. - POST to `/api/v2/job_templates/N/launch/` - can accept all prompt-able fields - POST to `/api/v2/workflow_job_templates/N/launch/` - - can only accept extra_vars + - can accept extra_vars and inventory - POST to `/api/v2/system_job_templates/N/launch/` - can accept certain fields, with no user configuration @@ -142,6 +142,7 @@ at launch-time that are saved in advance. - Workflow nodes - Schedules - Job relaunch / re-scheduling + - (partially) workflow job templates In the case of workflow nodes and schedules, the prompted fields are saved directly on the model. Those models include Workflow Job Template Nodes, @@ -157,7 +158,7 @@ and only used to prepare the correct launch-time configuration for subsequent re-launch and re-scheduling of the job. To see these prompts for a particular job, do a GET to `/api/v2/jobs/N/create_schedule/`. -#### Workflow Node Launch Configuration (Changing in Tower 3.3) +#### Workflow Node Launch Configuration Workflow job nodes will combine `extra_vars` from their parent workflow job with the variables that they provide in @@ -168,15 +169,26 @@ the node. All prompts that a workflow node passes to a spawned job abides by the rules of the related template. That means that if the node's job template has `ask_variables_on_launch` set -to false with no survey, neither the workflow JT or the artifacts will take effect -in the job that is spawned. +to false with no survey, the workflow node's variables will not +take effect in the job that is spawned. If the node's job template has `ask_inventory_on_launch` set to false and the node provides an inventory, this resource will not be used in the spawned job. If a user creates a node that would do this, a 400 response will be returned. -Behavior before the 3.3 release cycle was less-restrictive with passing -workflow variables to the jobs it spawned, allowing variables to take effect -even when the job template was not configured to allow it. +#### Workflow Job Template Prompts + +Workflow JTs are different than other cases, because they do not have a +template directly linked, so their prompts are a form of action-at-a-distance. +When the node's prompts are gathered, any prompts from the workflow job +can take precedence over the node's value. + +As a special exception, `extra_vars` from a workflow will not obey JT survey +and prompting rules, both both historical and ease-of-understanding reasons. +This behavior may change in the future. + +Other than that exception, JT prompting rules are still adhered to when +a job is spawned, although so far this only applies to the workflow job's +`inventory` field. #### Job Relaunch and Re-scheduling From a60401abb9f345b308d960c5ef41b2dfc7bb85b9 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 1 Oct 2018 09:53:41 -0400 Subject: [PATCH 05/42] fix bug with WFJT launch validation --- awx/api/serializers.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 1836d67830..122d330cb3 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4443,9 +4443,9 @@ class WorkflowJobLaunchSerializer(BaseSerializer): return dict(name=obj.name, id=obj.id, description=obj.description) def validate(self, attrs): - obj = self.instance + template = self.instance - accepted, rejected, errors = obj._accept_or_ignore_job_kwargs( + accepted, rejected, errors = template._accept_or_ignore_job_kwargs( _exclude_errors=['required'], **attrs) self._ignored_fields = rejected @@ -4458,11 +4458,11 @@ class WorkflowJobLaunchSerializer(BaseSerializer): if errors: raise serializers.ValidationError(errors) - WFJT_extra_vars = obj.extra_vars - WFJT_inventory = obj.inventory + WFJT_extra_vars = template.extra_vars + WFJT_inventory = template.inventory super(WorkflowJobLaunchSerializer, self).validate(attrs) - obj.extra_vars = WFJT_extra_vars - obj.inventory = WFJT_inventory + template.extra_vars = WFJT_extra_vars + template.inventory = WFJT_inventory return accepted From eb58a6cc0ef113ad1042f535153727898b432d62 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 1 Oct 2018 10:52:34 -0400 Subject: [PATCH 06/42] add test for launching with deleted inventory --- .../tests/functional/api/test_job_template.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/awx/main/tests/functional/api/test_job_template.py b/awx/main/tests/functional/api/test_job_template.py index c555aa75d4..9d62c70a69 100644 --- a/awx/main/tests/functional/api/test_job_template.py +++ b/awx/main/tests/functional/api/test_job_template.py @@ -6,7 +6,7 @@ import pytest # AWX from awx.api.serializers import JobTemplateSerializer from awx.api.versioning import reverse -from awx.main.models import Job, JobTemplate, CredentialType +from awx.main.models import Job, JobTemplate, CredentialType, WorkflowJobTemplate from awx.main.migrations import _save_password_keys as save_password_keys # Django @@ -519,6 +519,24 @@ def test_launch_with_pending_deletion_inventory(get, post, organization_factory, assert resp.data['inventory'] == ['The inventory associated with this Job Template is being deleted.'] +@pytest.mark.django_db +def test_launch_with_pending_deletion_inventory_workflow(get, post, organization, inventory, admin_user): + wfjt = WorkflowJobTemplate.objects.create( + name='wfjt', + organization=organization, + inventory=inventory + ) + + inventory.pending_deletion = True + inventory.save() + + resp = post( + url=reverse('api:workflow_job_template_launch', kwargs={'pk': wfjt.pk}), + user=admin_user, expect=400 + ) + assert resp.data['inventory'] == ['The inventory associated with this Workflow is being deleted.'] + + @pytest.mark.django_db def test_launch_with_extra_credentials(get, post, organization_factory, job_template_factory, machine_credential, From 6d4469ebbdf7726470f2c6432f6582781cf6a2db Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 1 Oct 2018 11:41:07 -0400 Subject: [PATCH 07/42] handle inventory for WFJT editing RBAC --- awx/main/access.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index 8fa83ab084..30e704d195 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1835,8 +1835,10 @@ class WorkflowJobTemplateAccess(BaseAccess): if 'survey_enabled' in data and data['survey_enabled']: self.check_license(feature='surveys') - return self.check_related('organization', Organization, data, role_field='workflow_admin_role', - mandatory=True) + return ( + self.check_related('organization', Organization, data, role_field='workflow_admin_role', mandatory=True) and + self.check_related('inventory', Inventory, data, role_field='use_role') + ) def can_copy(self, obj): if self.save_messages: @@ -1890,8 +1892,11 @@ class WorkflowJobTemplateAccess(BaseAccess): if self.user.is_superuser: return True - return (self.check_related('organization', Organization, data, role_field='workflow_admin_role', obj=obj) and - self.user in obj.admin_role) + return ( + self.check_related('organization', Organization, data, role_field='workflow_admin_role', obj=obj) and + self.check_related('inventory', Inventory, data, role_field='use_role', obj=obj) and + self.user in obj.admin_role + ) def can_delete(self, obj): return self.user.is_superuser or self.user in obj.admin_role From 5b3ce1e9999a1e96b7ce6dc5e8ae16adbd727bed Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Tue, 2 Oct 2018 09:44:41 -0400 Subject: [PATCH 08/42] add test for WFJT schedule inventory prompting --- .../tests/functional/api/test_schedules.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/awx/main/tests/functional/api/test_schedules.py b/awx/main/tests/functional/api/test_schedules.py index 21e3b91065..f8fb75a5bc 100644 --- a/awx/main/tests/functional/api/test_schedules.py +++ b/awx/main/tests/functional/api/test_schedules.py @@ -34,6 +34,30 @@ def test_wfjt_schedule_accepted(post, workflow_job_template, admin_user): post(url, {'name': 'test sch', 'rrule': RRULE_EXAMPLE}, admin_user, expect=201) +@pytest.mark.django_db +def test_wfjt_unprompted_inventory_rejected(post, workflow_job_template, inventory, admin_user): + r = post( + url=reverse('api:workflow_job_template_schedules_list', kwargs={'pk': workflow_job_template.id}), + data={'name': 'test sch', 'rrule': RRULE_EXAMPLE, 'inventory': inventory.pk}, + user=admin_user, + expect=400 + ) + assert r.data['inventory'] == ['Field is not configured to prompt on launch.'] + + +@pytest.mark.django_db +def test_wfjt_unprompted_inventory_accepted(post, workflow_job_template, inventory, admin_user): + workflow_job_template.ask_inventory_on_launch = True + workflow_job_template.save() + r = post( + url=reverse('api:workflow_job_template_schedules_list', kwargs={'pk': workflow_job_template.id}), + data={'name': 'test sch', 'rrule': RRULE_EXAMPLE, 'inventory': inventory.pk}, + user=admin_user, + expect=201 + ) + assert Schedule.objects.get(pk=r.data['id']).inventory == inventory + + @pytest.mark.django_db def test_valid_survey_answer(post, admin_user, project, inventory, survey_spec_factory): job_template = JobTemplate.objects.create( From 3c980d373c7dd1889fbe71b0e95415a4544eca8a Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 1 Nov 2018 10:08:57 -0400 Subject: [PATCH 09/42] bump migration number --- ...40_workflow_inventory.py => 0052_v340_workflow_inventory.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename awx/main/migrations/{0050_v340_workflow_inventory.py => 0052_v340_workflow_inventory.py} (95%) diff --git a/awx/main/migrations/0050_v340_workflow_inventory.py b/awx/main/migrations/0052_v340_workflow_inventory.py similarity index 95% rename from awx/main/migrations/0050_v340_workflow_inventory.py rename to awx/main/migrations/0052_v340_workflow_inventory.py index 8481c6c2d6..88139cdc7b 100644 --- a/awx/main/migrations/0050_v340_workflow_inventory.py +++ b/awx/main/migrations/0052_v340_workflow_inventory.py @@ -10,7 +10,7 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('main', '0049_v330_validate_instance_capacity_adjustment'), + ('main', '0051_v340_job_slicing'), ] operations = [ From 2d2164a4ba49a4b3cfaa5d62b1f7b328929253c5 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 7 Nov 2018 22:17:15 -0500 Subject: [PATCH 10/42] add inventory lookup to workflow detail view --- awx/ui/client/src/templates/main.js | 34 +++++++++++++++++++ awx/ui/client/src/templates/workflows.form.js | 15 ++++++++ .../add-workflow/workflow-add.controller.js | 11 ++++-- .../edit-workflow/workflow-edit.controller.js | 19 +++++++++-- 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/awx/ui/client/src/templates/main.js b/awx/ui/client/src/templates/main.js index 11282a3d29..1737771029 100644 --- a/awx/ui/client/src/templates/main.js +++ b/awx/ui/client/src/templates/main.js @@ -303,6 +303,23 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p }, resolve: { add: { + Inventory: ['$stateParams', 'Rest', 'GetBasePath', 'ProcessErrors', + function($stateParams, Rest, GetBasePath, ProcessErrors){ + if($stateParams.inventory_id){ + let path = `${GetBasePath('inventory')}${$stateParams.inventory_id}`; + Rest.setUrl(path); + return Rest.get(). + then(function(data){ + return data.data; + }).catch(function(response) { + ProcessErrors(null, response.data, response.status, null, { + hdr: 'Error!', + msg: 'Failed to get inventory info. GET returned status: ' + + response.status + }); + }); + } + }], availableLabels: ['Rest', '$stateParams', 'GetBasePath', 'ProcessErrors', 'TemplatesService', function(Rest, $stateParams, GetBasePath, ProcessErrors, TemplatesService) { return TemplatesService.getAllLabelOptions() @@ -354,6 +371,23 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p }, resolve: { edit: { + Inventory: ['$stateParams', 'Rest', 'GetBasePath', 'ProcessErrors', + function($stateParams, Rest, GetBasePath, ProcessErrors){ + if($stateParams.inventory_id){ + let path = `${GetBasePath('inventory')}${$stateParams.inventory_id}`; + Rest.setUrl(path); + return Rest.get(). + then(function(data){ + return data.data; + }).catch(function(response) { + ProcessErrors(null, response.data, response.status, null, { + hdr: 'Error!', + msg: 'Failed to get inventory info. GET returned status: ' + + response.status + }); + }); + } + }], availableLabels: ['Rest', '$stateParams', 'GetBasePath', 'ProcessErrors', 'TemplatesService', function(Rest, $stateParams, GetBasePath, ProcessErrors, TemplatesService) { return TemplatesService.getAllLabelOptions() diff --git a/awx/ui/client/src/templates/workflows.form.js b/awx/ui/client/src/templates/workflows.form.js index 50a3ee867c..017fcb06ba 100644 --- a/awx/ui/client/src/templates/workflows.form.js +++ b/awx/ui/client/src/templates/workflows.form.js @@ -68,6 +68,21 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n) { ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate) || !canEditOrg', awLookupWhen: '(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate) && canEditOrg' }, + inventory: { + label: i18n._('Inventory'), + type: 'lookup', + basePath: 'inventory', + list: 'InventoryList', + sourceModel: 'inventory', + sourceField: 'name', + autopopulateLookup: false, + column: 1, + awPopOver: "

" + i18n._("Select an inventory for the workflow. This inventory is applied to all job templates nodes that prompt for an inventory.") + "

", + dataTitle: i18n._('Inventory'), + dataPlacement: 'right', + dataContainer: "body", + ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate) || !canEditInventory', + }, labels: { label: i18n._('Labels'), type: 'select', diff --git a/awx/ui/client/src/templates/workflows/add-workflow/workflow-add.controller.js b/awx/ui/client/src/templates/workflows/add-workflow/workflow-add.controller.js index 84bfa0186d..e99a89a048 100644 --- a/awx/ui/client/src/templates/workflows/add-workflow/workflow-add.controller.js +++ b/awx/ui/client/src/templates/workflows/add-workflow/workflow-add.controller.js @@ -8,11 +8,11 @@ export default [ '$scope', 'WorkflowForm', 'GenerateForm', 'Alert', 'ProcessErrors', 'Wait', '$state', 'CreateSelect2', 'TemplatesService', 'ToJSON', 'ParseTypeChange', '$q', 'Rest', 'GetBasePath', 'availableLabels', 'i18n', - 'resolvedModels', + 'resolvedModels', 'Inventory', function($scope, WorkflowForm, GenerateForm, Alert, ProcessErrors, Wait, $state, CreateSelect2, TemplatesService, ToJSON, ParseTypeChange, $q, Rest, GetBasePath, availableLabels, i18n, - resolvedModels) { + resolvedModels, Inventory) { // Inject dynamic view let form = WorkflowForm(), @@ -23,6 +23,7 @@ export default [ $scope.canAddWorkflowJobTemplate = workflowTemplate.options('actions.POST'); $scope.canEditOrg = true; + $scope.canEditInventory = true; $scope.parseType = 'yaml'; $scope.can_edit = true; // apply form definition's default field values @@ -50,6 +51,12 @@ export default [ $scope.workflowEditorTooltip = i18n._("Please save before defining the workflow graph."); $scope.surveyTooltip = i18n._('Please save before adding a survey to this workflow.'); + + if (Inventory){ + $scope.inventory = Inventory.id; + $scope.inventory_name = Inventory.name; + } + $scope.formSave = function () { let fld, data = {}; diff --git a/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js b/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js index dc6a35d402..9d6db73ac0 100644 --- a/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js +++ b/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js @@ -10,12 +10,12 @@ export default [ 'Wait', 'Empty', 'ToJSON', 'initSurvey', '$state', 'CreateSelect2', 'ParseVariableString', 'TemplatesService', 'Rest', 'ToggleNotification', 'OrgAdminLookup', 'availableLabels', 'selectedLabels', 'workflowJobTemplateData', 'i18n', - 'workflowLaunch', '$transitions', 'WorkflowJobTemplateModel', + 'workflowLaunch', '$transitions', 'WorkflowJobTemplateModel', 'Inventory', function($scope, $stateParams, WorkflowForm, GenerateForm, Alert, ProcessErrors, GetBasePath, $q, ParseTypeChange, Wait, Empty, ToJSON, SurveyControllerInit, $state, CreateSelect2, ParseVariableString, TemplatesService, Rest, ToggleNotification, OrgAdminLookup, availableLabels, selectedLabels, workflowJobTemplateData, i18n, - workflowLaunch, $transitions, WorkflowJobTemplate + workflowLaunch, $transitions, WorkflowJobTemplate, Inventory, ) { $scope.missingTemplates = _.has(workflowLaunch, 'node_templates_missing') && workflowLaunch.node_templates_missing.length > 0 ? true : false; @@ -54,6 +54,11 @@ export default [ $scope.parseType = 'yaml'; $scope.includeWorkflowMaker = false; + if (Inventory){ + $scope.inventory = Inventory.id; + $scope.inventory_name = Inventory.name; + } + $scope.openWorkflowMaker = function() { $state.go('.workflowMaker'); }; @@ -312,6 +317,16 @@ export default [ $scope.canEditOrg = true; } + if(workflowJobTemplateData.inventory) { + OrgAdminLookup.checkForRoleLevelAdminAccess(workflowJobTemplateData.inventory, 'workflow_admin_role') + .then(function(canEditInventory){ + $scope.canEditInventory = canEditInventory; + }); + } + else { + $scope.canEditInventory = true; + } + $scope.url = workflowJobTemplateData.url; $scope.survey_enabled = workflowJobTemplateData.survey_enabled; From a94042def54db6542f783be889e1cb39f2cca0fb Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 7 Nov 2018 22:53:49 -0500 Subject: [PATCH 11/42] display inventory on workflow job details --- .../workflow-results.controller.js | 11 ++++++++++- .../workflow-results.partial.html | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/src/workflow-results/workflow-results.controller.js b/awx/ui/client/src/workflow-results/workflow-results.controller.js index fdb0acca79..f40e8867d1 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.controller.js +++ b/awx/ui/client/src/workflow-results/workflow-results.controller.js @@ -32,6 +32,14 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', $scope.cloud_credential_link = getLink('cloud_credential'); $scope.network_credential_link = getLink('network_credential'); + if ($scope.workflow.summary_fields.inventory) { + if ($scope.workflow.summary_fields.inventory.kind === 'smart') { + $scope.inventory_link = '/#/inventories/smart/' + $scope.workflow.inventory; + } else { + $scope.inventory_link = '/#/inventories/inventory/' + $scope.workflow.inventory; + } + } + $scope.strings = { tooltips: { RELAUNCH: i18n._('Relaunch using the same parameters'), @@ -54,7 +62,8 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', STATUS: i18n._('Status'), SLICE_TEMPLATE: i18n._('Slice Job Template'), JOB_EXPLANATION: i18n._('Explanation'), - SOURCE_WORKFLOW_JOB: i18n._('Source Workflow') + SOURCE_WORKFLOW_JOB: i18n._('Source Workflow'), + INVENTORY: i18n._('Inventory') }, details: { HEADER: i18n._('DETAILS'), diff --git a/awx/ui/client/src/workflow-results/workflow-results.partial.html b/awx/ui/client/src/workflow-results/workflow-results.partial.html index 7fbc1ae332..48dacb6e3f 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.partial.html +++ b/awx/ui/client/src/workflow-results/workflow-results.partial.html @@ -125,6 +125,21 @@ + +
+ + +
+
From 2376013d496c586dba3c4ee27c545a74843991d7 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 7 Nov 2018 23:16:43 -0500 Subject: [PATCH 12/42] add prompt on launch for workflow inventory --- awx/ui/client/lib/models/WorkflowJobTemplate.js | 6 ++++-- awx/ui/client/src/templates/workflows.form.js | 5 +++++ .../workflows/add-workflow/workflow-add.controller.js | 1 + .../workflows/edit-workflow/workflow-edit.controller.js | 2 ++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/lib/models/WorkflowJobTemplate.js b/awx/ui/client/lib/models/WorkflowJobTemplate.js index 79198bbe82..406aba3350 100644 --- a/awx/ui/client/lib/models/WorkflowJobTemplate.js +++ b/awx/ui/client/lib/models/WorkflowJobTemplate.js @@ -50,8 +50,10 @@ function canLaunchWithoutPrompt () { const launchData = this.model.launch.GET; return ( - launchData.can_start_without_user_input && - !launchData.survey_enabled + // TODO: may need api update + // launchData.can_start_without_user_input && + !launchData.survey_enabled && + !this.model.GET.ask_inventory_on_launch ); } diff --git a/awx/ui/client/src/templates/workflows.form.js b/awx/ui/client/src/templates/workflows.form.js index 017fcb06ba..fe3f8c24e7 100644 --- a/awx/ui/client/src/templates/workflows.form.js +++ b/awx/ui/client/src/templates/workflows.form.js @@ -81,6 +81,11 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n) { dataTitle: i18n._('Inventory'), dataPlacement: 'right', dataContainer: "body", + subCheckbox: { + variable: 'ask_inventory_on_launch', + ngChange: 'workflow_job_template_form.inventory_name.$validate()', + text: i18n._('Prompt on launch') + }, ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate) || !canEditInventory', }, labels: { diff --git a/awx/ui/client/src/templates/workflows/add-workflow/workflow-add.controller.js b/awx/ui/client/src/templates/workflows/add-workflow/workflow-add.controller.js index e99a89a048..45bbc4e78c 100644 --- a/awx/ui/client/src/templates/workflows/add-workflow/workflow-add.controller.js +++ b/awx/ui/client/src/templates/workflows/add-workflow/workflow-add.controller.js @@ -75,6 +75,7 @@ export default [ data[fld] = $scope[fld]; } } + data.ask_inventory_on_launch = Boolean($scope.ask_inventory_on_launch); data.extra_vars = ToJSON($scope.parseType, $scope.variables, true); diff --git a/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js b/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js index 9d6db73ac0..be7b125261 100644 --- a/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js +++ b/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js @@ -88,6 +88,8 @@ export default [ } } + data.ask_inventory_on_launch = Boolean($scope.ask_inventory_on_launch); + data.extra_vars = ToJSON($scope.parseType, $scope.variables, true); From 7178fb83b09c8aa2d9291756bdca59c1f37b61b5 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 8 Nov 2018 14:03:36 -0500 Subject: [PATCH 13/42] migration number bumped again --- ...40_workflow_inventory.py => 0053_v340_workflow_inventory.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename awx/main/migrations/{0052_v340_workflow_inventory.py => 0053_v340_workflow_inventory.py} (94%) diff --git a/awx/main/migrations/0052_v340_workflow_inventory.py b/awx/main/migrations/0053_v340_workflow_inventory.py similarity index 94% rename from awx/main/migrations/0052_v340_workflow_inventory.py rename to awx/main/migrations/0053_v340_workflow_inventory.py index 88139cdc7b..285b4262fe 100644 --- a/awx/main/migrations/0052_v340_workflow_inventory.py +++ b/awx/main/migrations/0053_v340_workflow_inventory.py @@ -10,7 +10,7 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('main', '0051_v340_job_slicing'), + ('main', '0052_v340_remove_project_scm_delete_on_next_update'), ] operations = [ From 2bd25b1fbaa012ef70682eb90abff3a6f7955179 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Sat, 10 Nov 2018 18:15:08 -0500 Subject: [PATCH 14/42] add inventory prompt to wf editor --- .../launchTemplateButton.component.js | 4 +- awx/ui/client/lib/models/JobTemplate.js | 13 +++- .../client/lib/models/WorkflowJobTemplate.js | 62 ++++++++++++++----- .../src/templates/prompt/prompt.service.js | 16 ++--- .../add-workflow/workflow-add.controller.js | 10 +-- .../edit-workflow/workflow-edit.controller.js | 2 +- .../workflow-maker.controller.js | 25 +++----- 7 files changed, 80 insertions(+), 52 deletions(-) diff --git a/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js b/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js index 20cf1d8e94..d31ea36f33 100644 --- a/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js +++ b/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js @@ -97,12 +97,12 @@ function atLaunchTemplateCtrl ( extra_vars: wfjtData.data.extra_vars }; const promptData = { - launchConf: launchData.data, + launchConf: selectedWorkflowJobTemplate.getLaunchConf(), launchOptions: launchOptions.data, template: vm.template.id, templateType: vm.template.type, prompts: PromptService.processPromptValues({ - launchConf: launchData.data, + launchConf: selectedWorkflowJobTemplate.getLaunchConf(), launchOptions: launchOptions.data }), triggerModalOpen: true, diff --git a/awx/ui/client/lib/models/JobTemplate.js b/awx/ui/client/lib/models/JobTemplate.js index b1b3599f4b..7e845f8a1e 100644 --- a/awx/ui/client/lib/models/JobTemplate.js +++ b/awx/ui/client/lib/models/JobTemplate.js @@ -47,8 +47,15 @@ function getSurveyQuestions (id) { return $http(req); } +function getLaunchConf () { + // this method is just a pass-through to the underlying launch GET data + // we use it to make the access patterns consistent across both types of + // templates + return this.model.launch.GET; +} + function canLaunchWithoutPrompt () { - const launchData = this.model.launch.GET; + const launchData = this.getLaunchConf(); return ( launchData.can_start_without_user_input && @@ -61,7 +68,8 @@ function canLaunchWithoutPrompt () { !launchData.ask_skip_tags_on_launch && !launchData.ask_variables_on_launch && !launchData.ask_diff_mode_on_launch && - !launchData.survey_enabled + !launchData.survey_enabled && + launchData.variables_needed_to_start.length === 0 ); } @@ -85,6 +93,7 @@ function JobTemplateModel (method, resource, config) { this.getLaunch = getLaunch.bind(this); this.postLaunch = postLaunch.bind(this); this.getSurveyQuestions = getSurveyQuestions.bind(this); + this.getLaunchConf = getLaunchConf.bind(this); this.canLaunchWithoutPrompt = canLaunchWithoutPrompt.bind(this); this.model.launch = {}; diff --git a/awx/ui/client/lib/models/WorkflowJobTemplate.js b/awx/ui/client/lib/models/WorkflowJobTemplate.js index 406aba3350..5ce646de6e 100644 --- a/awx/ui/client/lib/models/WorkflowJobTemplate.js +++ b/awx/ui/client/lib/models/WorkflowJobTemplate.js @@ -1,5 +1,7 @@ +/* eslint camelcase: 0 */ let Base; let $http; +let $q; function optionsLaunch (id) { const req = { @@ -11,16 +13,19 @@ function optionsLaunch (id) { } function getLaunch (id) { - const req = { - method: 'GET', - url: `${this.path}${id}/launch/` - }; + const urls = [ + `${this.path}${id}/`, + `${this.path}${id}/launch/`, + ]; - return $http(req) - .then(res => { - this.model.launch.GET = res.data; + const promises = urls.map(url => $http({ method: 'GET', url })); - return res; + return $q.all(promises) + .then(([res, launchRes]) => { + this.model.GET = res.data; + this.model.launch.GET = launchRes.data; + + return launchRes; }); } @@ -46,14 +51,40 @@ function getSurveyQuestions (id) { return $http(req); } +function getLaunchConf () { + // We may need api updates to align /:id/launch data with what is returned for job templates. + // For now, we splice values from the different endpoints to get the launchData we need. + const { + ask_inventory_on_launch, + ask_variables_on_launch, + survey_enabled, + } = this.model.GET; + + const { + can_start_without_user_input, + variables_needed_to_start, + } = this.model.launch.GET; + + const launchConf = { + ask_inventory_on_launch, + ask_variables_on_launch, + can_start_without_user_input, + survey_enabled, + variables_needed_to_start, + }; + + return launchConf; +} + function canLaunchWithoutPrompt () { - const launchData = this.model.launch.GET; + const launchData = this.getLaunchConf(); return ( - // TODO: may need api update - // launchData.can_start_without_user_input && + launchData.can_start_without_user_input && + !launchData.ask_inventory_on_launch && + !launchData.ask_variables_on_launch && !launchData.survey_enabled && - !this.model.GET.ask_inventory_on_launch + launchData.variables_needed_to_start.length === 0 ); } @@ -65,6 +96,7 @@ function WorkflowJobTemplateModel (method, resource, config) { this.getLaunch = getLaunch.bind(this); this.postLaunch = postLaunch.bind(this); this.getSurveyQuestions = getSurveyQuestions.bind(this); + this.getLaunchConf = getLaunchConf.bind(this); this.canLaunchWithoutPrompt = canLaunchWithoutPrompt.bind(this); this.model.launch = {}; @@ -72,16 +104,18 @@ function WorkflowJobTemplateModel (method, resource, config) { return this.create(method, resource, config); } -function WorkflowJobTemplateModelLoader (BaseModel, _$http_) { +function WorkflowJobTemplateModelLoader (BaseModel, _$http_, _$q_) { Base = BaseModel; $http = _$http_; + $q = _$q_; return WorkflowJobTemplateModel; } WorkflowJobTemplateModelLoader.$inject = [ 'BaseModel', - '$http' + '$http', + '$q', ]; export default WorkflowJobTemplateModelLoader; diff --git a/awx/ui/client/src/templates/prompt/prompt.service.js b/awx/ui/client/src/templates/prompt/prompt.service.js index 12fe5d4470..30b45412d3 100644 --- a/awx/ui/client/src/templates/prompt/prompt.service.js +++ b/awx/ui/client/src/templates/prompt/prompt.service.js @@ -242,28 +242,30 @@ function PromptService (Empty, $filter) { } } + const launchConfDefaults = _.get(params, ['promptData', 'launchConf', 'defaults'], {}); + if(_.has(params, 'promptData.prompts.jobType.value.value') && _.get(params, 'promptData.launchConf.ask_job_type_on_launch')) { - promptDataToSave.job_type = params.promptData.launchConf.defaults.job_type && params.promptData.launchConf.defaults.job_type === params.promptData.prompts.jobType.value.value ? null : params.promptData.prompts.jobType.value.value; + promptDataToSave.job_type = launchConfDefaults.job_type && launchConfDefaults.job_type === params.promptData.prompts.jobType.value.value ? null : params.promptData.prompts.jobType.value.value; } if(_.has(params, 'promptData.prompts.tags.value') && _.get(params, 'promptData.launchConf.ask_tags_on_launch')){ - const templateDefaultJobTags = params.promptData.launchConf.defaults.job_tags.split(','); + const templateDefaultJobTags = launchConfDefaults.job_tags.split(','); promptDataToSave.job_tags = (_.isEqual(templateDefaultJobTags.sort(), params.promptData.prompts.tags.value.map(a => a.value).sort())) ? null : params.promptData.prompts.tags.value.map(a => a.value).join(); } if(_.has(params, 'promptData.prompts.skipTags.value') && _.get(params, 'promptData.launchConf.ask_skip_tags_on_launch')){ - const templateDefaultSkipTags = params.promptData.launchConf.defaults.skip_tags.split(','); + const templateDefaultSkipTags = launchConfDefaults.skip_tags.split(','); promptDataToSave.skip_tags = (_.isEqual(templateDefaultSkipTags.sort(), params.promptData.prompts.skipTags.value.map(a => a.value).sort())) ? null : params.promptData.prompts.skipTags.value.map(a => a.value).join(); } if(_.has(params, 'promptData.prompts.limit.value') && _.get(params, 'promptData.launchConf.ask_limit_on_launch')){ - promptDataToSave.limit = params.promptData.launchConf.defaults.limit && params.promptData.launchConf.defaults.limit === params.promptData.prompts.limit.value ? null : params.promptData.prompts.limit.value; + promptDataToSave.limit = launchConfDefaults.limit && launchConfDefaults.limit === params.promptData.prompts.limit.value ? null : params.promptData.prompts.limit.value; } if(_.has(params, 'promptData.prompts.verbosity.value.value') && _.get(params, 'promptData.launchConf.ask_verbosity_on_launch')){ - promptDataToSave.verbosity = params.promptData.launchConf.defaults.verbosity && params.promptData.launchConf.defaults.verbosity === params.promptData.prompts.verbosity.value.value ? null : params.promptData.prompts.verbosity.value.value; + promptDataToSave.verbosity = launchConfDefaults.verbosity && launchConfDefaults.verbosity === params.promptData.prompts.verbosity.value.value ? null : params.promptData.prompts.verbosity.value.value; } if(_.has(params, 'promptData.prompts.inventory.value') && _.get(params, 'promptData.launchConf.ask_inventory_on_launch')){ - promptDataToSave.inventory = params.promptData.launchConf.defaults.inventory && params.promptData.launchConf.defaults.inventory.id === params.promptData.prompts.inventory.value.id ? null : params.promptData.prompts.inventory.value.id; + promptDataToSave.inventory = launchConfDefaults.inventory && launchConfDefaults.inventory.id === params.promptData.prompts.inventory.value.id ? null : params.promptData.prompts.inventory.value.id; } if(_.has(params, 'promptData.prompts.diffMode.value') && _.get(params, 'promptData.launchConf.ask_diff_mode_on_launch')){ - promptDataToSave.diff_mode = params.promptData.launchConf.defaults.diff_mode && params.promptData.launchConf.defaults.diff_mode === params.promptData.prompts.diffMode.value ? null : params.promptData.prompts.diffMode.value; + promptDataToSave.diff_mode = launchConfDefaults.diff_mode && launchConfDefaults.diff_mode === params.promptData.prompts.diffMode.value ? null : params.promptData.prompts.diffMode.value; } return promptDataToSave; diff --git a/awx/ui/client/src/templates/workflows/add-workflow/workflow-add.controller.js b/awx/ui/client/src/templates/workflows/add-workflow/workflow-add.controller.js index 45bbc4e78c..12eae06883 100644 --- a/awx/ui/client/src/templates/workflows/add-workflow/workflow-add.controller.js +++ b/awx/ui/client/src/templates/workflows/add-workflow/workflow-add.controller.js @@ -8,11 +8,11 @@ export default [ '$scope', 'WorkflowForm', 'GenerateForm', 'Alert', 'ProcessErrors', 'Wait', '$state', 'CreateSelect2', 'TemplatesService', 'ToJSON', 'ParseTypeChange', '$q', 'Rest', 'GetBasePath', 'availableLabels', 'i18n', - 'resolvedModels', 'Inventory', + 'resolvedModels', function($scope, WorkflowForm, GenerateForm, Alert, ProcessErrors, Wait, $state, CreateSelect2, TemplatesService, ToJSON, ParseTypeChange, $q, Rest, GetBasePath, availableLabels, i18n, - resolvedModels, Inventory) { + resolvedModels) { // Inject dynamic view let form = WorkflowForm(), @@ -51,12 +51,6 @@ export default [ $scope.workflowEditorTooltip = i18n._("Please save before defining the workflow graph."); $scope.surveyTooltip = i18n._('Please save before adding a survey to this workflow.'); - - if (Inventory){ - $scope.inventory = Inventory.id; - $scope.inventory_name = Inventory.name; - } - $scope.formSave = function () { let fld, data = {}; diff --git a/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js b/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js index be7b125261..24dbe03257 100644 --- a/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js +++ b/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js @@ -15,7 +15,7 @@ export default [ ProcessErrors, GetBasePath, $q, ParseTypeChange, Wait, Empty, ToJSON, SurveyControllerInit, $state, CreateSelect2, ParseVariableString, TemplatesService, Rest, ToggleNotification, OrgAdminLookup, availableLabels, selectedLabels, workflowJobTemplateData, i18n, - workflowLaunch, $transitions, WorkflowJobTemplate, Inventory, + workflowLaunch, $transitions, WorkflowJobTemplate, Inventory ) { $scope.missingTemplates = _.has(workflowLaunch, 'node_templates_missing') && workflowLaunch.node_templates_missing.length > 0 ? true : false; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index 0c8f4fbf1b..7653631db1 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -568,7 +568,6 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', /* EDIT NODE FUNCTIONS */ $scope.startEditNode = function (nodeToEdit) { - if (!$scope.nodeBeingEdited || ($scope.nodeBeingEdited && $scope.nodeBeingEdited.id !== nodeToEdit.id)) { if ($scope.placeholderNode || $scope.nodeBeingEdited) { $scope.cancelNodeForm(); @@ -1005,7 +1004,8 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', $q.all([jobTemplate.optionsLaunch(selectedTemplate.id), jobTemplate.getLaunch(selectedTemplate.id)]) .then((responses) => { - let launchConf = responses[1].data; + const launchConf = jobTemplate.getLaunchConf(); + if (selectedTemplate.type === 'job_template') { if ((!selectedTemplate.inventory && !launchConf.ask_inventory_on_launch) || !selectedTemplate.project) { $scope.selectedTemplateInvalid = true; @@ -1022,24 +1022,13 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', $scope.selectedTemplate = angular.copy(selectedTemplate); - if (!launchConf.survey_enabled && - !launchConf.ask_inventory_on_launch && - !launchConf.ask_credential_on_launch && - !launchConf.ask_verbosity_on_launch && - !launchConf.ask_job_type_on_launch && - !launchConf.ask_limit_on_launch && - !launchConf.ask_tags_on_launch && - !launchConf.ask_skip_tags_on_launch && - !launchConf.ask_diff_mode_on_launch && - !launchConf.credential_needed_to_start && - !launchConf.ask_variables_on_launch && - launchConf.variables_needed_to_start.length === 0) { + if (jobTemplate.canLaunchWithoutPrompt()) { $scope.showPromptButton = false; $scope.promptModalMissingReqFields = false; } else { $scope.showPromptButton = true; - if (selectedTemplate.type === 'job_template') { + if (['job_template', 'workflow_job_template'].includes(selectedTemplate.type)) { if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory')) { $scope.promptModalMissingReqFields = true; } else { @@ -1059,9 +1048,8 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', }); $scope.missingSurveyValue = processed.missingSurveyValue; - $scope.promptData = { - launchConf: responses[1].data, + launchConf, launchOptions: responses[0].data, surveyQuestions: processed.surveyQuestions, template: selectedTemplate.id, @@ -1084,8 +1072,9 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', watchForPromptChanges(); }); } else { + $scope.promptData = { - launchConf: responses[1].data, + launchConf, launchOptions: responses[0].data, template: selectedTemplate.id, prompts: PromptService.processPromptValues({ From 38fbcf8ee6ea97e10e8410ed475e0752c280895d Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Sat, 10 Nov 2018 22:44:09 -0500 Subject: [PATCH 15/42] add missing api fields --- awx/api/serializers.py | 18 +++++-- .../launchTemplateButton.component.js | 5 +- .../client/lib/models/WorkflowJobTemplate.js | 47 ++++--------------- 3 files changed, 27 insertions(+), 43 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 122d330cb3..f9d21b0c7d 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4418,6 +4418,7 @@ class JobLaunchSerializer(BaseSerializer): class WorkflowJobLaunchSerializer(BaseSerializer): can_start_without_user_input = serializers.BooleanField(read_only=True) + defaults = serializers.SerializerMethodField() variables_needed_to_start = serializers.ReadOnlyField() survey_enabled = serializers.SerializerMethodField() extra_vars = VerbatimField(required=False, write_only=True) @@ -4429,16 +4430,27 @@ class WorkflowJobLaunchSerializer(BaseSerializer): class Meta: model = WorkflowJobTemplate - fields = ('can_start_without_user_input', 'extra_vars', 'inventory', - 'survey_enabled', 'variables_needed_to_start', + fields = ('ask_inventory_on_launch', 'can_start_without_user_input', 'defaults', 'extra_vars', + 'inventory', 'survey_enabled', 'variables_needed_to_start', 'node_templates_missing', 'node_prompts_rejected', - 'workflow_job_template_data') + 'workflow_job_template_data', 'survey_enabled') + read_only_fields = ('ask_inventory_on_launch',) def get_survey_enabled(self, obj): if obj: return obj.survey_enabled and 'spec' in obj.survey_spec return False + def get_defaults(self, obj): + defaults ={ + 'inventory': { + 'name': getattrd(obj, 'inventory.name', None), + 'id': getattrd(obj, 'inventory.pk', None) + } + } + + return defaults + def get_workflow_job_template_data(self, obj): return dict(name=obj.name, id=obj.id, description=obj.description) diff --git a/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js b/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js index d31ea36f33..4574ef6fc5 100644 --- a/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js +++ b/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js @@ -93,9 +93,8 @@ function atLaunchTemplateCtrl ( $state.go('workflowResults', { id: data.workflow_job }, { reload: true }); }); } else { - launchData.data.defaults = { - extra_vars: wfjtData.data.extra_vars - }; + launchData.data.defaults.extra_vars = wfjtData.data.extra_vars; + const promptData = { launchConf: selectedWorkflowJobTemplate.getLaunchConf(), launchOptions: launchOptions.data, diff --git a/awx/ui/client/lib/models/WorkflowJobTemplate.js b/awx/ui/client/lib/models/WorkflowJobTemplate.js index 5ce646de6e..ae649c03fb 100644 --- a/awx/ui/client/lib/models/WorkflowJobTemplate.js +++ b/awx/ui/client/lib/models/WorkflowJobTemplate.js @@ -1,7 +1,6 @@ /* eslint camelcase: 0 */ let Base; let $http; -let $q; function optionsLaunch (id) { const req = { @@ -13,19 +12,16 @@ function optionsLaunch (id) { } function getLaunch (id) { - const urls = [ - `${this.path}${id}/`, - `${this.path}${id}/launch/`, - ]; + const req = { + method: 'GET', + url: `${this.path}${id}/launch/` + }; - const promises = urls.map(url => $http({ method: 'GET', url })); + return $http(req) + .then(res => { + this.model.launch.GET = res.data; - return $q.all(promises) - .then(([res, launchRes]) => { - this.model.GET = res.data; - this.model.launch.GET = launchRes.data; - - return launchRes; + return res; }); } @@ -52,28 +48,7 @@ function getSurveyQuestions (id) { } function getLaunchConf () { - // We may need api updates to align /:id/launch data with what is returned for job templates. - // For now, we splice values from the different endpoints to get the launchData we need. - const { - ask_inventory_on_launch, - ask_variables_on_launch, - survey_enabled, - } = this.model.GET; - - const { - can_start_without_user_input, - variables_needed_to_start, - } = this.model.launch.GET; - - const launchConf = { - ask_inventory_on_launch, - ask_variables_on_launch, - can_start_without_user_input, - survey_enabled, - variables_needed_to_start, - }; - - return launchConf; + return this.model.launch.GET; } function canLaunchWithoutPrompt () { @@ -104,10 +79,9 @@ function WorkflowJobTemplateModel (method, resource, config) { return this.create(method, resource, config); } -function WorkflowJobTemplateModelLoader (BaseModel, _$http_, _$q_) { +function WorkflowJobTemplateModelLoader (BaseModel, _$http_) { Base = BaseModel; $http = _$http_; - $q = _$q_; return WorkflowJobTemplateModel; } @@ -115,7 +89,6 @@ function WorkflowJobTemplateModelLoader (BaseModel, _$http_, _$q_) { WorkflowJobTemplateModelLoader.$inject = [ 'BaseModel', '$http', - '$q', ]; export default WorkflowJobTemplateModelLoader; From 38f43c147af3542d8ce89f183b60b4691e3e8589 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Sat, 10 Nov 2018 23:09:04 -0500 Subject: [PATCH 16/42] fix exploding unit test --- .../test/spec/workflows/workflow-add.controller-test.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/awx/ui/test/spec/workflows/workflow-add.controller-test.js b/awx/ui/test/spec/workflows/workflow-add.controller-test.js index 9eceb528ce..e7879b5a8b 100644 --- a/awx/ui/test/spec/workflows/workflow-add.controller-test.js +++ b/awx/ui/test/spec/workflows/workflow-add.controller-test.js @@ -142,14 +142,15 @@ describe('Controller: WorkflowAdd', () => { expect(TemplatesService.createWorkflowJobTemplate).toHaveBeenCalledWith({ name: "Test Workflow", description: "This is a test description", - labels: undefined, organization: undefined, + inventory: undefined, + labels: undefined, variables: undefined, - extra_vars: undefined, - allow_simultaneous: undefined + allow_simultaneous: undefined, + ask_inventory_on_launch: false, + extra_vars: undefined }); }); - }); describe('scope.formCancel()', () => { From f8453ffe68608f288c4ab76e93a79c249a924538 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Sun, 11 Nov 2018 01:01:20 -0500 Subject: [PATCH 17/42] accept inventory_id in workflow launch requests --- awx/api/views/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 5233635197..cbc879097e 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3113,6 +3113,9 @@ class WorkflowJobTemplateLaunch(WorkflowsEnforcementMixin, RetrieveAPIView): def post(self, request, *args, **kwargs): obj = self.get_object() + if 'inventory_id' in request.data: + request.data['inventory'] = request.data['inventory_id'] + serializer = self.serializer_class(instance=obj, data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) From a8d22b9459ca248fe4222d21b662ae986dd5020a Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Sun, 11 Nov 2018 01:31:13 -0500 Subject: [PATCH 18/42] show correct ask_inventory state --- .../workflows/edit-workflow/workflow-edit.controller.js | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js b/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js index 24dbe03257..fc695c8482 100644 --- a/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js +++ b/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js @@ -53,6 +53,7 @@ export default [ $scope.mode = 'edit'; $scope.parseType = 'yaml'; $scope.includeWorkflowMaker = false; + $scope.ask_inventory_on_launch = workflowJobTemplateData.ask_inventory_on_launch; if (Inventory){ $scope.inventory = Inventory.id; From 4ea7511ae8169dc85c98bcd2aeae2baf2eb921f8 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Sun, 11 Nov 2018 02:06:01 -0500 Subject: [PATCH 19/42] make workflow prompt inventory step optional --- awx/ui/client/src/templates/prompt/prompt.controller.js | 6 ++++++ awx/ui/client/src/templates/prompt/prompt.partial.html | 2 +- .../workflows/workflow-maker/workflow-maker.controller.js | 4 ++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/src/templates/prompt/prompt.controller.js b/awx/ui/client/src/templates/prompt/prompt.controller.js index 1c5c33a6c5..5377efbecf 100644 --- a/awx/ui/client/src/templates/prompt/prompt.controller.js +++ b/awx/ui/client/src/templates/prompt/prompt.controller.js @@ -15,11 +15,17 @@ export default [ 'Rest', 'GetBasePath', 'ProcessErrors', 'CredentialTypeModel', scope = _scope_; ({ modal } = scope[scope.ns]); + vm.isInventoryOptional = false; + scope.$watch('vm.promptData.triggerModalOpen', () => { vm.actionButtonClicked = false; if(vm.promptData && vm.promptData.triggerModalOpen) { + if (vm.promptData.templateType === "workflow_job_template") { + vm.isInventoryOptional = true; + } + scope.$emit('launchModalOpen', true); vm.promptDataClone = _.cloneDeep(vm.promptData); diff --git a/awx/ui/client/src/templates/prompt/prompt.partial.html b/awx/ui/client/src/templates/prompt/prompt.partial.html index 4f3d17847e..0023ca2c9c 100644 --- a/awx/ui/client/src/templates/prompt/prompt.partial.html +++ b/awx/ui/client/src/templates/prompt/prompt.partial.html @@ -45,7 +45,7 @@ `; } + if (options.mode === 'lookup' && options.lookupMessage) { + html = `
${options.lookupMessage}
` + html; + } + return html; }, diff --git a/awx/ui/client/src/shared/stateDefinitions.factory.js b/awx/ui/client/src/shared/stateDefinitions.factory.js index b8f46207db..2c54e901f7 100644 --- a/awx/ui/client/src/shared/stateDefinitions.factory.js +++ b/awx/ui/client/src/shared/stateDefinitions.factory.js @@ -806,13 +806,19 @@ function($injector, $stateExtender, $log, i18n) { views: { 'modal': { templateProvider: function(ListDefinition, generateList) { - let list_html = generateList.build({ + const listConfig = { mode: 'lookup', list: ListDefinition, input_type: 'radio' - }); - return `${list_html}`; + }; + if (field.lookupMessage) { + listConfig.lookupMessage = field.lookupMessage; + } + + let list_html = generateList.build(listConfig); + + return `${list_html}`; } } }, diff --git a/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.directive.js b/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.directive.js index 4f6c4eed8d..4bf34ad35d 100644 --- a/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.directive.js +++ b/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.directive.js @@ -6,8 +6,8 @@ import promptInventoryController from './prompt-inventory.controller'; -export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$compile', 'InventoryList', - (templateUrl, qs, GetBasePath, GenerateList, $compile, InventoryList) => { +export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$compile', 'InventoryList', 'i18n', + (templateUrl, qs, GetBasePath, GenerateList, $compile, InventoryList, i18n) => { return { scope: { promptData: '=', @@ -46,11 +46,18 @@ export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$com let invList = _.cloneDeep(InventoryList); invList.disableRow = "{{ readOnlyPrompts }}"; invList.disableRowValue = "readOnlyPrompts"; - let html = GenerateList.build({ + + const listConfig = { list: invList, input_type: 'radio', - mode: 'lookup' - }); + mode: 'lookup', + }; + + if (scope.promptData.templateType === "workflow_job_template") { + listConfig.lookupMessage = i18n._("This inventory is applied to all job templates nodes that prompt for an inventory."); + } + + let html = GenerateList.build(listConfig); scope.list = invList; diff --git a/awx/ui/client/src/templates/workflows.form.js b/awx/ui/client/src/templates/workflows.form.js index c1b10b4cab..751a0858fd 100644 --- a/awx/ui/client/src/templates/workflows.form.js +++ b/awx/ui/client/src/templates/workflows.form.js @@ -71,6 +71,7 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n) { inventory: { label: i18n._('Inventory'), type: 'lookup', + lookupMessage: i18n._("This inventory is applied to all job templates nodes that prompt for an inventory."), basePath: 'inventory', list: 'InventoryList', sourceModel: 'inventory', From 75c2d1eda1446d32c90f7ef3525efdb60fca6e95 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Sun, 11 Nov 2018 23:23:49 -0500 Subject: [PATCH 24/42] add inventory help messages for workflow node edit --- .../features/templates/templates.strings.js | 7 +++- .../workflow-maker.controller.js | 40 +++++++++++++++++++ .../workflow-maker.partial.html | 3 ++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index d33fcf1b81..0efcff23c4 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -117,9 +117,12 @@ function TemplatesStrings (BaseString) { DELETED: t.s('DELETED'), START: t.s('START'), DETAILS: t.s('DETAILS'), - TITLE: t.s('WORKFLOW VISUALIZER') + TITLE: t.s('WORKFLOW VISUALIZER'), + INVENTORY_WILL_OVERRIDE: t.s('The inventory of this node will be overridden by the parent workflow inventory.'), + INVENTORY_WILL_NOT_OVERRIDE: t.s('The inventory of this node will not be overridden by the parent workflow inventory.'), + INVENTORY_PROMPT_WILL_OVERRIDE: t.s('The inventory of this node will be overridden if a parent workflow inventory is provided at launch.'), + INVENTORY_PROMPT_WILL_NOT_OVERRIDE: t.s('The inventory of this node will not be overridden if a parent workflow inventory is provided at launch.'), } - } TemplatesStrings.$inject = ['BaseStringService']; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index 5643b4c86c..b0dd90eb35 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -571,6 +571,8 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', /* EDIT NODE FUNCTIONS */ $scope.startEditNode = function (nodeToEdit) { + $scope.editNodeHelpMessage = null; + if (!$scope.nodeBeingEdited || ($scope.nodeBeingEdited && $scope.nodeBeingEdited.id !== nodeToEdit.id)) { if ($scope.placeholderNode || $scope.nodeBeingEdited) { $scope.cancelNodeForm(); @@ -989,6 +991,42 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', } }; + function getEditNodeHelpMessage(workflowTemplate, selectedTemplate) { + if (selectedTemplate.type === "workflow_job_template") { + if (workflowTemplate.inventory) { + if (selectedTemplate.ask_inventory_on_launch) { + return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE'); + } + } + + if (workflowTemplate.ask_inventory_on_launch) { + if (selectedTemplate.ask_inventory_on_launch) { + return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_OVERRIDE'); + } + } + } + + if (selectedTemplate.type === "job_template") { + if (workflowTemplate.inventory) { + if (selectedTemplate.ask_inventory_on_launch) { + return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE'); + } + + return $scope.strings.get('workflow_maker.INVENTORY_WILL_NOT_OVERRIDE'); + } + + if (workflowTemplate.ask_inventory_on_launch) { + if (selectedTemplate.ask_inventory_on_launch) { + return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_OVERRIDE'); + } + + return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_NOT_OVERRIDE'); + } + } + + return null; + } + $scope.templateManuallySelected = function (selectedTemplate) { if (promptWatcher) { @@ -1004,6 +1042,8 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', } $scope.promptData = null; + $scope.editNodeHelpMessage = getEditNodeHelpMessage($scope.treeData.workflow_job_template_obj, selectedTemplate); + if (selectedTemplate.type === "job_template" || selectedTemplate.type === "workflow_job_template") { let jobTemplate = selectedTemplate.type === "workflow_job_template" ? new WorkflowJobTemplate() : new JobTemplate(); diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html index e92cc0e9bf..0a1357007f 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html @@ -133,6 +133,9 @@
+
+
From 75566bad398cfbeaa9a8e8efafc14a6a77f80ef2 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 12 Nov 2018 00:26:30 -0500 Subject: [PATCH 25/42] fix workflow e2e tests --- .../e2e/tests/test-templates-list-actions.js | 104 +++++++++++------- .../e2e/tests/test-workflow-visualizer.js | 43 ++++++-- 2 files changed, 93 insertions(+), 54 deletions(-) diff --git a/awx/ui/test/e2e/tests/test-templates-list-actions.js b/awx/ui/test/e2e/tests/test-templates-list-actions.js index fbebe75d94..617c3f590d 100644 --- a/awx/ui/test/e2e/tests/test-templates-list-actions.js +++ b/awx/ui/test/e2e/tests/test-templates-list-actions.js @@ -140,56 +140,76 @@ module.exports = { templates.expect.element('@save').enabled; client.pause(1000); - templates.section.editWorkflowJobTemplate - .waitForElementVisible('@visualizerButton') - .click('@visualizerButton') - .waitForElementVisible('div.spinny') - .waitForElementNotVisible('div.spinny') - .waitForAngular(); + client.getValue('#workflow_job_template_name', result => { + templates.expect.element('#workflow_job_template_cancel_btn').visible; + templates.click('#workflow_job_template_cancel_btn'); + client.refresh(); + client.waitForElementVisible('div.spinny'); + client.waitForElementNotVisible('div.spinny'); - client.expect.element('#workflow-modal-dialog').visible; - client.expect.element('#workflow-modal-dialog span[class^="badge"]').visible; - client.expect.element('#workflow-modal-dialog span[class^="badge"]').text.equal('3'); - client.expect.element('div[class="WorkflowMaker-title"]').visible; - client.expect.element('div[class="WorkflowMaker-title"]').text.contain(data.workflow.name); - client.expect.element('div[class="WorkflowMaker-title"]').text.not.equal(data.workflow.name); + templates.expect.element('smart-search').visible; + templates.expect.element('smart-search input').enabled; - client.expect.element('#workflow-modal-dialog i[class*="fa-cog"]').visible; - client.expect.element('#workflow-modal-dialog i[class*="fa-cog"]').enabled; + templates + .sendKeys('smart-search input', `name.iexact:"${result.value}"`) + .sendKeys('smart-search input', client.Keys.ENTER); - client.click('#workflow-modal-dialog i[class*="fa-cog"]'); + templates.waitForElementVisible('div.spinny'); + templates.waitForElementNotVisible('div.spinny'); - client.waitForElementVisible('workflow-controls'); - client.waitForElementVisible('div[class*="-zoomPercentage"]'); + templates.expect.element('.at-Panel-headingTitleBadge').text.equal('1').before(10000); + templates.expect.element('div[ui-view="templatesList"] i[class*="sitemap"]').visible; + templates.expect.element('div[ui-view="templatesList"] i[class*="sitemap"]').enabled; - client.click('i[class*="fa-home"]').expect.element('div[class*="-zoomPercentage"]').text.equal('100%'); - client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('90%'); - client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('80%'); - client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('70%'); - client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('60%'); + client + .click('div[ui-view="templatesList"] i[class*="sitemap"]') + .pause(1500) + .waitForElementNotVisible('div.spinny'); - client.expect.element('#node-1').visible; - client.expect.element('#node-2').visible; - client.expect.element('#node-3').visible; - client.expect.element('#node-4').visible; + client.expect.element('#workflow-modal-dialog').visible; + client.expect.element('#workflow-modal-dialog span[class^="badge"]').visible; + client.expect.element('#workflow-modal-dialog span[class^="badge"]').text.equal('3'); + client.expect.element('div[class="WorkflowMaker-title"]').visible; + client.expect.element('div[class="WorkflowMaker-title"]').text.contain(data.workflow.name); + client.expect.element('div[class="WorkflowMaker-title"]').text.not.equal(data.workflow.name); - client.expect.element('#node-1 text').text.not.equal('').after(5000); - client.expect.element('#node-2 text').text.not.equal('').after(5000); - client.expect.element('#node-3 text').text.not.equal('').after(5000); - client.expect.element('#node-4 text').text.not.equal('').after(5000); + client.expect.element('#workflow-modal-dialog i[class*="fa-cog"]').visible; + client.expect.element('#workflow-modal-dialog i[class*="fa-cog"]').enabled; - const checkNodeText = (selector, text) => client.getText(selector, ({ value }) => { - client.assert.equal(text.indexOf(value.replace('...', '')) >= 0, true); + client.click('#workflow-modal-dialog i[class*="fa-cog"]'); + + client.waitForElementVisible('workflow-controls'); + client.waitForElementVisible('div[class*="-zoomPercentage"]'); + + client.click('i[class*="fa-home"]').expect.element('div[class*="-zoomPercentage"]').text.equal('100%'); + client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('90%'); + client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('80%'); + client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('70%'); + client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('60%'); + + client.expect.element('#node-1').visible; + client.expect.element('#node-2').visible; + client.expect.element('#node-3').visible; + client.expect.element('#node-4').visible; + + client.expect.element('#node-1 text').text.not.equal('').after(5000); + client.expect.element('#node-2 text').text.not.equal('').after(5000); + client.expect.element('#node-3 text').text.not.equal('').after(5000); + client.expect.element('#node-4 text').text.not.equal('').after(5000); + + const checkNodeText = (selector, text) => client.getText(selector, ({ value }) => { + client.assert.equal(text.indexOf(value.replace('...', '')) >= 0, true); + }); + + checkNodeText('#node-1 text', 'START'); + checkNodeText('#node-2 text', data.project.name); + checkNodeText('#node-3 text', data.template.name); + checkNodeText('#node-4 text', data.source.name); + + templates.expect.element('@save').visible; + templates.expect.element('@save').enabled; + + client.end(); }); - - checkNodeText('#node-1 text', 'START'); - checkNodeText('#node-2 text', data.project.name); - checkNodeText('#node-3 text', data.template.name); - checkNodeText('#node-4 text', data.source.name); - - templates.expect.element('@save').visible; - templates.expect.element('@save').enabled; - - client.end(); } }; diff --git a/awx/ui/test/e2e/tests/test-workflow-visualizer.js b/awx/ui/test/e2e/tests/test-workflow-visualizer.js index 7cccd3aec3..8e65e7069f 100644 --- a/awx/ui/test/e2e/tests/test-workflow-visualizer.js +++ b/awx/ui/test/e2e/tests/test-workflow-visualizer.js @@ -53,20 +53,39 @@ module.exports = { data = { source, template, project, workflow }; done(); }); + }, + 'navigate to the workflow template visualizer': client => { + const templates = client.page.templates(); + + client.useCss(); + client.resizeWindow(1200, 800); + client.login(); + client.waitForAngular(); + + templates.load(); + templates.waitForElementVisible('div.spinny'); + templates.waitForElementNotVisible('div.spinny'); + + templates.expect.element('smart-search').visible; + templates.expect.element('smart-search input').enabled; + + templates + .sendKeys('smart-search input', `id:>${data.workflow.id - 1} id:<${data.workflow.id + 1}`) + .sendKeys('smart-search input', client.Keys.ENTER); + + templates.waitForElementVisible('div.spinny'); + templates.waitForElementNotVisible('div.spinny'); + + templates.expect.element('.at-Panel-headingTitleBadge').text.equal('1').before(10000); + templates.expect.element(`#row-${data.workflow.id}`).visible; + templates.expect.element('div[ui-view="templatesList"] i[class*="sitemap"]').visible; + templates.expect.element('div[ui-view="templatesList"] i[class*="sitemap"]').enabled; + client - .login() - .waitForAngular() - .resizeWindow(1200, 1000) - .useXpath() - .findThenClick(workflowTemplateNavTab) + .click('div[ui-view="templatesList"] i[class*="sitemap"]') .pause(1500) - .waitForElementNotVisible(spinny) - .clearValue(workflowSearchBar) - .setValue(workflowSearchBar, [workflowText, client.Keys.ENTER]) - .waitForElementVisible(workflowSearchBadgeCount) - .waitForElementNotVisible(spinny) - .findThenClick(workflowSelector) - .findThenClick(workflowVisualizerBtn); + .useXpath() + .waitForElementNotVisible(spinny); }, 'verify that workflow visualizer root node can only be set to always': client => { client From c1d85f568c520b7fc7d0ba3d2831435cd805ffbb Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 12 Nov 2018 11:00:21 -0500 Subject: [PATCH 26/42] fix survey vars bug and inventory defaults display --- awx/api/serializers.py | 17 +++++++++-------- awx/api/views/__init__.py | 2 ++ awx/main/models/workflow.py | 1 + 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index f9d21b0c7d..236549a2fa 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4442,14 +4442,15 @@ class WorkflowJobLaunchSerializer(BaseSerializer): return False def get_defaults(self, obj): - defaults ={ - 'inventory': { - 'name': getattrd(obj, 'inventory.name', None), - 'id': getattrd(obj, 'inventory.pk', None) - } - } - - return defaults + defaults_dict = {} + for field_name in WorkflowJobTemplate.get_ask_mapping().keys(): + if field_name == 'inventory': + defaults_dict[field_name] = dict( + name=getattrd(obj, '%s.name' % field_name, None), + id=getattrd(obj, '%s.pk' % field_name, None)) + else: + defaults_dict[field_name] = getattr(obj, field_name) + return defaults_dict def get_workflow_job_template_data(self, obj): return dict(name=obj.name, id=obj.id, description=obj.description) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index cbc879097e..87140b02b1 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3108,6 +3108,8 @@ class WorkflowJobTemplateLaunch(WorkflowsEnforcementMixin, RetrieveAPIView): data['extra_vars'] = extra_vars if obj.ask_inventory_on_launch: data['inventory'] = obj.inventory_id + else: + data.pop('inventory', None) return data def post(self, request, *args, **kwargs): diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 0f988afb79..4d6cad4b4e 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -440,6 +440,7 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl if rejected_vars: rejected_data['extra_vars'] = rejected_vars errors_dict.update(vars_errors) + continue if field_name not in kwargs: continue From 89a0be64af457338a77c40cd2e9236bb6da51991 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 12 Nov 2018 15:53:56 -0500 Subject: [PATCH 27/42] fix bug with opening visualizer from list page --- awx/ui/client/features/templates/templatesList.controller.js | 2 +- .../workflows/workflow-maker/workflow-maker.partial.html | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/awx/ui/client/features/templates/templatesList.controller.js b/awx/ui/client/features/templates/templatesList.controller.js index 4efd534ad4..7bf634b1db 100644 --- a/awx/ui/client/features/templates/templatesList.controller.js +++ b/awx/ui/client/features/templates/templatesList.controller.js @@ -104,7 +104,7 @@ function ListTemplatesController( vm.openWorkflowVisualizer = template => { const name = 'templates.editWorkflowJobTemplate.workflowMaker'; const params = { workflow_job_template_id: template.id }; - const options = { reload: false }; + const options = { reload: true }; $state.go(name, params, options); }; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html index 0a1357007f..7760f5b7aa 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html @@ -133,9 +133,8 @@
-
- +
+
From c105885c7b033d7208a85fed868b64b30c391a46 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Tue, 13 Nov 2018 10:04:44 -0500 Subject: [PATCH 28/42] Do not count template variables as prompted --- awx/main/models/mixins.py | 9 +++- .../tests/unit/models/test_survey_models.py | 51 ++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 7dcb560aa2..58ced65010 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -301,6 +301,13 @@ class SurveyJobTemplateMixin(models.Model): accepted.update(extra_vars) extra_vars = {} + if extra_vars: + # Prune the prompted variables for those identical to template + tmp_extra_vars = self.extra_vars_dict + for key in (set(tmp_extra_vars.keys()) & set(extra_vars.keys())): + if tmp_extra_vars[key] == extra_vars[key]: + extra_vars.pop(key) + if extra_vars: # Leftover extra_vars, keys provided that are not allowed rejected.update(extra_vars) @@ -308,7 +315,7 @@ class SurveyJobTemplateMixin(models.Model): if 'prompts' not in _exclude_errors: errors['extra_vars'] = [_('Variables {list_of_keys} are not allowed on launch. Check the Prompt on Launch setting '+ 'on the Job Template to include Extra Variables.').format( - list_of_keys=', '.join(extra_vars.keys()))] + list_of_keys=six.text_type(', ').join([six.text_type(key) for key in extra_vars.keys()]))] return (accepted, rejected, errors) diff --git a/awx/main/tests/unit/models/test_survey_models.py b/awx/main/tests/unit/models/test_survey_models.py index 3bc06edc87..e63f428922 100644 --- a/awx/main/tests/unit/models/test_survey_models.py +++ b/awx/main/tests/unit/models/test_survey_models.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import tempfile import json import yaml @@ -10,7 +11,9 @@ from awx.main.models import ( Job, JobTemplate, JobLaunchConfig, - WorkflowJobTemplate + WorkflowJobTemplate, + Project, + Inventory ) from awx.main.utils.safe_yaml import SafeLoader @@ -305,3 +308,49 @@ class TestWorkflowSurveys: ) assert wfjt.variables_needed_to_start == ['question2'] assert not wfjt.can_start_without_user_input() + + +@pytest.mark.django_db +@pytest.mark.parametrize('provided_vars,valid', [ + ({'tmpl_var': 'bar'}, True), # same as template, not counted as prompts + ({'tmpl_var': 'bar2'}, False), # different value from template, not okay + ({'tmpl_var': 'bar', 'a': 2}, False), # extra key, not okay + ({'tmpl_var': 'bar', False: 2}, False), # Falsy key + ({'tmpl_var': 'bar', u'🐉': u'🐉'}, False), # dragons +]) +class TestExtraVarsNoPrompt: + def process_vars_and_assert(self, tmpl, provided_vars, valid): + prompted_fields, ignored_fields, errors = tmpl._accept_or_ignore_job_kwargs( + extra_vars=provided_vars + ) + if valid: + assert not ignored_fields + assert not errors + else: + assert ignored_fields + assert errors + + def test_jt_extra_vars_counting(self, provided_vars, valid): + jt = JobTemplate( + name='foo', + extra_vars={'tmpl_var': 'bar'}, + project=Project(), + project_id=42, + playbook='helloworld.yml', + inventory=Inventory(), + inventory_id=42 + ) + prompted_fields, ignored_fields, errors = jt._accept_or_ignore_job_kwargs( + extra_vars=provided_vars + ) + self.process_vars_and_assert(jt, provided_vars, valid) + + def test_wfjt_extra_vars_counting(self, provided_vars, valid): + wfjt = WorkflowJobTemplate( + name='foo', + extra_vars={'tmpl_var': 'bar'} + ) + prompted_fields, ignored_fields, errors = wfjt._accept_or_ignore_job_kwargs( + extra_vars=provided_vars + ) + self.process_vars_and_assert(wfjt, provided_vars, valid) From e0a28e32eba2e5487fedf601d58eb5aac1977e00 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Tue, 13 Nov 2018 13:16:43 -0500 Subject: [PATCH 29/42] Tweak of error message wording for model-specific name --- awx/main/models/mixins.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 58ced65010..d6dbc3e4fa 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -314,8 +314,9 @@ class SurveyJobTemplateMixin(models.Model): # ignored variables does not block manual launch if 'prompts' not in _exclude_errors: errors['extra_vars'] = [_('Variables {list_of_keys} are not allowed on launch. Check the Prompt on Launch setting '+ - 'on the Job Template to include Extra Variables.').format( - list_of_keys=six.text_type(', ').join([six.text_type(key) for key in extra_vars.keys()]))] + 'on the {model_name} to include Extra Variables.').format( + list_of_keys=six.text_type(', ').join([six.text_type(key) for key in extra_vars.keys()]), + model_name=self._meta.verbose_name.title())] return (accepted, rejected, errors) From 018a8e12debc80caa5e7ee964c728768a5a7c3b0 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 14 Nov 2018 13:27:13 -0500 Subject: [PATCH 30/42] fix lookup message --- .../prompt/steps/inventory/prompt-inventory.directive.js | 2 +- awx/ui/client/src/templates/workflows.form.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.directive.js b/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.directive.js index 4bf34ad35d..7177964518 100644 --- a/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.directive.js +++ b/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.directive.js @@ -54,7 +54,7 @@ export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$com }; if (scope.promptData.templateType === "workflow_job_template") { - listConfig.lookupMessage = i18n._("This inventory is applied to all job templates nodes that prompt for an inventory."); + listConfig.lookupMessage = i18n._("This inventory is applied to all job template nodes that prompt for an inventory."); } let html = GenerateList.build(listConfig); diff --git a/awx/ui/client/src/templates/workflows.form.js b/awx/ui/client/src/templates/workflows.form.js index 751a0858fd..b531798248 100644 --- a/awx/ui/client/src/templates/workflows.form.js +++ b/awx/ui/client/src/templates/workflows.form.js @@ -71,14 +71,14 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n) { inventory: { label: i18n._('Inventory'), type: 'lookup', - lookupMessage: i18n._("This inventory is applied to all job templates nodes that prompt for an inventory."), + lookupMessage: i18n._("This inventory is applied to all job template nodes that prompt for an inventory."), basePath: 'inventory', list: 'InventoryList', sourceModel: 'inventory', sourceField: 'name', autopopulateLookup: false, column: 1, - awPopOver: "

" + i18n._("Select an inventory for the workflow. This inventory is applied to all job templates nodes that prompt for an inventory.") + "

", + awPopOver: "

" + i18n._("Select an inventory for the workflow. This inventory is applied to all job template nodes that prompt for an inventory.") + "

", dataTitle: i18n._('Inventory'), dataPlacement: 'right', dataContainer: "body", From bca9bcf6ddc3cd7f0b87f5856079c8c2a8527fa1 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 15 Nov 2018 13:43:23 -0500 Subject: [PATCH 31/42] fix prompts contradiction: should be non-functional change --- awx/api/serializers.py | 4 +--- awx/main/models/workflow.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 236549a2fa..eba6bbcf4e 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4458,9 +4458,7 @@ class WorkflowJobLaunchSerializer(BaseSerializer): def validate(self, attrs): template = self.instance - accepted, rejected, errors = template._accept_or_ignore_job_kwargs( - _exclude_errors=['required'], - **attrs) + accepted, rejected, errors = template._accept_or_ignore_job_kwargs(**attrs) self._ignored_fields = rejected if template.inventory and template.inventory.pending_deletion is True: diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 4d6cad4b4e..1ede7d1b31 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -420,7 +420,7 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl workflow_job.copy_nodes_from_original(original=self) return workflow_job - def _accept_or_ignore_job_kwargs(self, _exclude_errors=(), **kwargs): + def _accept_or_ignore_job_kwargs(self, **kwargs): exclude_errors = kwargs.pop('_exclude_errors', []) prompted_data = {} rejected_data = {} From ecbdc5595514574417d01e1ed9b893e4676f5d34 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Sat, 17 Nov 2018 13:34:09 -0500 Subject: [PATCH 32/42] show related workflow counts on inventory deletion warning prompt --- awx/ui/client/lib/models/Inventory.js | 13 +++++++++++-- awx/ui/client/lib/models/models.strings.js | 4 ++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/lib/models/Inventory.js b/awx/ui/client/lib/models/Inventory.js index 3d270dfc79..828dac1055 100644 --- a/awx/ui/client/lib/models/Inventory.js +++ b/awx/ui/client/lib/models/Inventory.js @@ -1,5 +1,6 @@ let Base; let JobTemplate; +let WorkflowJobTemplate; function setDependentResources (id) { this.dependentResources = [ @@ -8,6 +9,12 @@ function setDependentResources (id) { params: { inventory: id } + }, + { + model: new WorkflowJobTemplate(), + params: { + inventory: id + } } ]; } @@ -21,16 +28,18 @@ function InventoryModel (method, resource, config) { return this.create(method, resource, config); } -function InventoryModelLoader (BaseModel, JobTemplateModel) { +function InventoryModelLoader (BaseModel, JobTemplateModel, WorkflowJobTemplateModel) { Base = BaseModel; JobTemplate = JobTemplateModel; + WorkflowJobTemplate = WorkflowJobTemplateModel; return InventoryModel; } InventoryModelLoader.$inject = [ 'BaseModel', - 'JobTemplateModel' + 'JobTemplateModel', + 'WorkflowJobTemplateModel', ]; export default InventoryModelLoader; diff --git a/awx/ui/client/lib/models/models.strings.js b/awx/ui/client/lib/models/models.strings.js index acb2dd5bbf..c9385e3db5 100644 --- a/awx/ui/client/lib/models/models.strings.js +++ b/awx/ui/client/lib/models/models.strings.js @@ -41,6 +41,10 @@ function ModelsStrings (BaseString) { }; + ns.workflow_job_templates = { + LABEL: t.s('Workflow Job Templates') + }; + ns.workflow_job_template_nodes = { LABEL: t.s('Workflow Job Template Nodes') From fed00a18ad70d341e7c3011f9978ab5c105da688 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Sat, 17 Nov 2018 14:27:41 -0500 Subject: [PATCH 33/42] show workflow jobs on inventory completed jobs view --- .../features/jobs/routes/inventoryCompletedJobs.route.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/features/jobs/routes/inventoryCompletedJobs.route.js b/awx/ui/client/features/jobs/routes/inventoryCompletedJobs.route.js index 8d4de65623..d7d774d1a0 100644 --- a/awx/ui/client/features/jobs/routes/inventoryCompletedJobs.route.js +++ b/awx/ui/client/features/jobs/routes/inventoryCompletedJobs.route.js @@ -50,7 +50,9 @@ export default { const searchParam = _.assign($stateParams.job_search, { or__job__inventory: inventoryId, or__adhoccommand__inventory: inventoryId, - or__inventoryupdate__inventory_source__inventory: inventoryId }); + or__inventoryupdate__inventory_source__inventory: inventoryId, + or__workflowjob__inventory: inventoryId, + }); const searchPath = GetBasePath('unified_jobs'); From c6a7d0859d02a7272767dda45c8cd60fa8c5011c Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Sat, 17 Nov 2018 15:41:50 -0500 Subject: [PATCH 34/42] add workflow jobs to inventory list status popup --- .../host-summary-popover.controller.js | 18 ++++++++++++++---- .../host-summary-popover.directive.js | 4 ++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/awx/ui/client/src/inventories-hosts/inventories/list/host-summary-popover/host-summary-popover.controller.js b/awx/ui/client/src/inventories-hosts/inventories/list/host-summary-popover/host-summary-popover.controller.js index 77bab5de67..3db9145d61 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/list/host-summary-popover/host-summary-popover.controller.js +++ b/awx/ui/client/src/inventories-hosts/inventories/list/host-summary-popover/host-summary-popover.controller.js @@ -5,9 +5,13 @@ export default [ '$scope', 'Empty', 'Wait', 'GetBasePath', 'Rest', 'ProcessError if (!Empty($scope.inventory.id)) { if ($scope.inventory.total_hosts > 0) { Wait('start'); - let url = GetBasePath('jobs') + "?type=job&inventory=" + $scope.inventory.id + "&failed="; - url += ($scope.inventory.has_active_failures) ? "true" : "false"; + + let url = GetBasePath('unified_jobs') + '?'; + url += `&or__job__inventory=${$scope.inventory.id}`; + url += `&or__workflowjob__inventory=${$scope.inventory.id}`; + url += `&failed=${$scope.inventory.has_active_failures ? "true" : "false"}`; url += "&order_by=-finished&page_size=5"; + Rest.setUrl(url); Rest.get() .then(({data}) => { @@ -22,8 +26,14 @@ export default [ '$scope', 'Empty', 'Wait', 'GetBasePath', 'Rest', 'ProcessError } }; - $scope.viewJob = function(jobId) { - $state.go('output', { id: jobId, type: 'playbook' }); + $scope.viewJob = function(jobId, type) { + let outputType = 'playbook'; + + if (type === 'workflow_job') { + $state.go('workflowResults', { id: jobId}, { reload: true }); + } else { + $state.go('output', { id: jobId, type: outputType }); + } }; } diff --git a/awx/ui/client/src/inventories-hosts/inventories/list/host-summary-popover/host-summary-popover.directive.js b/awx/ui/client/src/inventories-hosts/inventories/list/host-summary-popover/host-summary-popover.directive.js index 7e6ae14f79..82d598bfa6 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/list/host-summary-popover/host-summary-popover.directive.js +++ b/awx/ui/client/src/inventories-hosts/inventories/list/host-summary-popover/host-summary-popover.directive.js @@ -60,10 +60,10 @@ export default ['templateUrl', 'Wait', '$filter', '$compile', 'i18n', data.results.forEach(function(row) { if ((scope.inventory.has_active_failures && row.status === 'failed') || (!scope.inventory.has_active_failures && row.status === 'successful')) { html += "\n"; - html += "\n"; html += "" + ($filter('longDate')(row.finished)) + ""; - html += "" + $filter('sanitize')(ellipsis(row.name)) + ""; html += "\n"; } From 0e3bf6db09fdcae0af5a0e06e8ad9d94d56744fd Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Sat, 17 Nov 2018 16:07:52 -0500 Subject: [PATCH 35/42] open workflow visualizer from form --- awx/ui/client/src/templates/workflows.form.js | 8 ++++++++ .../workflows/edit-workflow/workflow-edit.controller.js | 5 +++++ .../workflows/workflow-maker/workflow-maker.controller.js | 1 - 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/src/templates/workflows.form.js b/awx/ui/client/src/templates/workflows.form.js index b531798248..eece22f6fe 100644 --- a/awx/ui/client/src/templates/workflows.form.js +++ b/awx/ui/client/src/templates/workflows.form.js @@ -238,6 +238,14 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n) { class: 'Form-primaryButton', awToolTip: '{{surveyTooltip}}', dataPlacement: 'top' + }, + workflow_visualizer: { + ngClick: 'openWorkflowMaker()', + ngShow: '$state.is(\'templates.addWorkflowJobTemplate\') || $state.is(\'templates.editWorkflowJobTemplate\') || $state.is(\'templates.editWorkflowJobTemplate.workflowMaker\')', + awToolTip: '{{workflowVisualizerTooltip}}', + dataPlacement: 'top', + label: i18n._('Workflow Visualizer'), + class: 'Form-primaryButton' } } }; diff --git a/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js b/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js index 0b78eba354..fc695c8482 100644 --- a/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js +++ b/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js @@ -60,6 +60,10 @@ export default [ $scope.inventory_name = Inventory.name; } + $scope.openWorkflowMaker = function() { + $state.go('.workflowMaker'); + }; + $scope.formSave = function () { let fld, data = {}; $scope.invalid_survey = false; @@ -262,6 +266,7 @@ export default [ opts: opts }); + $scope.workflowVisualizerTooltip = i18n._("Click here to open the workflow visualizer."); $scope.surveyTooltip = i18n._('Surveys allow users to be prompted at job launch with a series of questions related to the job. This allows for variables to be defined that affect the playbook run at time of launch.'); $scope.workflow_job_template_obj = workflowJobTemplateData; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index b0dd90eb35..8a8258220b 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -360,7 +360,6 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', // Revert the data to the master which was created when the dialog was opened $scope.treeData.data = angular.copy($scope.treeDataMaster); $scope.closeDialog(); - $state.transitionTo('templates'); }; $scope.saveWorkflowMaker = function () { From fabe56088d515f9147af63d4cf293276b8c9ac6c Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Sat, 17 Nov 2018 16:39:18 -0500 Subject: [PATCH 36/42] fix workflow e2e tests again --- awx/ui/test/e2e/tests/test-org-permissions.js | 8 +- .../e2e/tests/test-templates-list-actions.js | 104 +++++++----------- .../e2e/tests/test-workflow-visualizer.js | 43 ++------ 3 files changed, 59 insertions(+), 96 deletions(-) diff --git a/awx/ui/test/e2e/tests/test-org-permissions.js b/awx/ui/test/e2e/tests/test-org-permissions.js index 4db48e8b23..42fe54fe03 100644 --- a/awx/ui/test/e2e/tests/test-org-permissions.js +++ b/awx/ui/test/e2e/tests/test-org-permissions.js @@ -50,10 +50,12 @@ const readOrgPermissionResults = '//*[@id="permissions_table"]//*[text()="test-a module.exports = { before: (client, done) => { + const namespace = 'test-org-permissions'; + const resources = [ - getUserExact('test-actions', 'test-actions-user'), - getOrganization('test-actions'), - getTeam('test-actions'), + getUserExact(namespace, `${namespace}-user`), + getOrganization(namespace), + getTeam(namespace), ]; Promise.all(resources) diff --git a/awx/ui/test/e2e/tests/test-templates-list-actions.js b/awx/ui/test/e2e/tests/test-templates-list-actions.js index 617c3f590d..fbebe75d94 100644 --- a/awx/ui/test/e2e/tests/test-templates-list-actions.js +++ b/awx/ui/test/e2e/tests/test-templates-list-actions.js @@ -140,76 +140,56 @@ module.exports = { templates.expect.element('@save').enabled; client.pause(1000); - client.getValue('#workflow_job_template_name', result => { - templates.expect.element('#workflow_job_template_cancel_btn').visible; - templates.click('#workflow_job_template_cancel_btn'); - client.refresh(); - client.waitForElementVisible('div.spinny'); - client.waitForElementNotVisible('div.spinny'); + templates.section.editWorkflowJobTemplate + .waitForElementVisible('@visualizerButton') + .click('@visualizerButton') + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny') + .waitForAngular(); - templates.expect.element('smart-search').visible; - templates.expect.element('smart-search input').enabled; + client.expect.element('#workflow-modal-dialog').visible; + client.expect.element('#workflow-modal-dialog span[class^="badge"]').visible; + client.expect.element('#workflow-modal-dialog span[class^="badge"]').text.equal('3'); + client.expect.element('div[class="WorkflowMaker-title"]').visible; + client.expect.element('div[class="WorkflowMaker-title"]').text.contain(data.workflow.name); + client.expect.element('div[class="WorkflowMaker-title"]').text.not.equal(data.workflow.name); - templates - .sendKeys('smart-search input', `name.iexact:"${result.value}"`) - .sendKeys('smart-search input', client.Keys.ENTER); + client.expect.element('#workflow-modal-dialog i[class*="fa-cog"]').visible; + client.expect.element('#workflow-modal-dialog i[class*="fa-cog"]').enabled; - templates.waitForElementVisible('div.spinny'); - templates.waitForElementNotVisible('div.spinny'); + client.click('#workflow-modal-dialog i[class*="fa-cog"]'); - templates.expect.element('.at-Panel-headingTitleBadge').text.equal('1').before(10000); - templates.expect.element('div[ui-view="templatesList"] i[class*="sitemap"]').visible; - templates.expect.element('div[ui-view="templatesList"] i[class*="sitemap"]').enabled; + client.waitForElementVisible('workflow-controls'); + client.waitForElementVisible('div[class*="-zoomPercentage"]'); - client - .click('div[ui-view="templatesList"] i[class*="sitemap"]') - .pause(1500) - .waitForElementNotVisible('div.spinny'); + client.click('i[class*="fa-home"]').expect.element('div[class*="-zoomPercentage"]').text.equal('100%'); + client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('90%'); + client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('80%'); + client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('70%'); + client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('60%'); - client.expect.element('#workflow-modal-dialog').visible; - client.expect.element('#workflow-modal-dialog span[class^="badge"]').visible; - client.expect.element('#workflow-modal-dialog span[class^="badge"]').text.equal('3'); - client.expect.element('div[class="WorkflowMaker-title"]').visible; - client.expect.element('div[class="WorkflowMaker-title"]').text.contain(data.workflow.name); - client.expect.element('div[class="WorkflowMaker-title"]').text.not.equal(data.workflow.name); + client.expect.element('#node-1').visible; + client.expect.element('#node-2').visible; + client.expect.element('#node-3').visible; + client.expect.element('#node-4').visible; - client.expect.element('#workflow-modal-dialog i[class*="fa-cog"]').visible; - client.expect.element('#workflow-modal-dialog i[class*="fa-cog"]').enabled; + client.expect.element('#node-1 text').text.not.equal('').after(5000); + client.expect.element('#node-2 text').text.not.equal('').after(5000); + client.expect.element('#node-3 text').text.not.equal('').after(5000); + client.expect.element('#node-4 text').text.not.equal('').after(5000); - client.click('#workflow-modal-dialog i[class*="fa-cog"]'); - - client.waitForElementVisible('workflow-controls'); - client.waitForElementVisible('div[class*="-zoomPercentage"]'); - - client.click('i[class*="fa-home"]').expect.element('div[class*="-zoomPercentage"]').text.equal('100%'); - client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('90%'); - client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('80%'); - client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('70%'); - client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('60%'); - - client.expect.element('#node-1').visible; - client.expect.element('#node-2').visible; - client.expect.element('#node-3').visible; - client.expect.element('#node-4').visible; - - client.expect.element('#node-1 text').text.not.equal('').after(5000); - client.expect.element('#node-2 text').text.not.equal('').after(5000); - client.expect.element('#node-3 text').text.not.equal('').after(5000); - client.expect.element('#node-4 text').text.not.equal('').after(5000); - - const checkNodeText = (selector, text) => client.getText(selector, ({ value }) => { - client.assert.equal(text.indexOf(value.replace('...', '')) >= 0, true); - }); - - checkNodeText('#node-1 text', 'START'); - checkNodeText('#node-2 text', data.project.name); - checkNodeText('#node-3 text', data.template.name); - checkNodeText('#node-4 text', data.source.name); - - templates.expect.element('@save').visible; - templates.expect.element('@save').enabled; - - client.end(); + const checkNodeText = (selector, text) => client.getText(selector, ({ value }) => { + client.assert.equal(text.indexOf(value.replace('...', '')) >= 0, true); }); + + checkNodeText('#node-1 text', 'START'); + checkNodeText('#node-2 text', data.project.name); + checkNodeText('#node-3 text', data.template.name); + checkNodeText('#node-4 text', data.source.name); + + templates.expect.element('@save').visible; + templates.expect.element('@save').enabled; + + client.end(); } }; diff --git a/awx/ui/test/e2e/tests/test-workflow-visualizer.js b/awx/ui/test/e2e/tests/test-workflow-visualizer.js index 8e65e7069f..7cccd3aec3 100644 --- a/awx/ui/test/e2e/tests/test-workflow-visualizer.js +++ b/awx/ui/test/e2e/tests/test-workflow-visualizer.js @@ -53,39 +53,20 @@ module.exports = { data = { source, template, project, workflow }; done(); }); - }, - 'navigate to the workflow template visualizer': client => { - const templates = client.page.templates(); - - client.useCss(); - client.resizeWindow(1200, 800); - client.login(); - client.waitForAngular(); - - templates.load(); - templates.waitForElementVisible('div.spinny'); - templates.waitForElementNotVisible('div.spinny'); - - templates.expect.element('smart-search').visible; - templates.expect.element('smart-search input').enabled; - - templates - .sendKeys('smart-search input', `id:>${data.workflow.id - 1} id:<${data.workflow.id + 1}`) - .sendKeys('smart-search input', client.Keys.ENTER); - - templates.waitForElementVisible('div.spinny'); - templates.waitForElementNotVisible('div.spinny'); - - templates.expect.element('.at-Panel-headingTitleBadge').text.equal('1').before(10000); - templates.expect.element(`#row-${data.workflow.id}`).visible; - templates.expect.element('div[ui-view="templatesList"] i[class*="sitemap"]').visible; - templates.expect.element('div[ui-view="templatesList"] i[class*="sitemap"]').enabled; - client - .click('div[ui-view="templatesList"] i[class*="sitemap"]') - .pause(1500) + .login() + .waitForAngular() + .resizeWindow(1200, 1000) .useXpath() - .waitForElementNotVisible(spinny); + .findThenClick(workflowTemplateNavTab) + .pause(1500) + .waitForElementNotVisible(spinny) + .clearValue(workflowSearchBar) + .setValue(workflowSearchBar, [workflowText, client.Keys.ENTER]) + .waitForElementVisible(workflowSearchBadgeCount) + .waitForElementNotVisible(spinny) + .findThenClick(workflowSelector) + .findThenClick(workflowVisualizerBtn); }, 'verify that workflow visualizer root node can only be set to always': client => { client From 2bc75270e7ecb6cb7d238052fe264da04bb53584 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Sat, 17 Nov 2018 20:52:17 -0500 Subject: [PATCH 37/42] dry up org permissions test --- awx/ui/test/e2e/tests/test-org-permissions.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/awx/ui/test/e2e/tests/test-org-permissions.js b/awx/ui/test/e2e/tests/test-org-permissions.js index 42fe54fe03..88e4f084b1 100644 --- a/awx/ui/test/e2e/tests/test-org-permissions.js +++ b/awx/ui/test/e2e/tests/test-org-permissions.js @@ -4,6 +4,8 @@ import { getTeam, } from '../fixtures'; +const namespace = 'test-org-permissions'; + let data; const spinny = "//*[contains(@class, 'spinny')]"; const checkbox = '//input[@type="checkbox"]'; @@ -23,7 +25,7 @@ const teamsTab = '//*[@id="teams_tab"]'; const permissionsTab = '//*[@id="permissions_tab"]'; const usersTab = '//*[@id="users_tab"]'; -const orgsText = 'name.iexact:"test-actions-organization"'; +const orgsText = `name.iexact:"${namespace}-organization"`; const orgsCheckbox = '//select-list-item[@item="organization"]//input[@type="checkbox"]'; const orgDetails = '//*[contains(@class, "OrgCards-label")]'; const orgRoleSelector = '//*[contains(@aria-labelledby, "select2-organizations")]'; @@ -32,12 +34,12 @@ const readRole = '//*[contains(@id, "organizations-role") and text()="Read"]'; const memberRoleText = 'member'; const readRoleText = 'read'; -const teamsSelector = "//a[contains(text(), 'test-actions-team')]"; -const teamsText = 'name.iexact:"test-actions-team"'; +const teamsSelector = `//a[contains(text(), '${namespace}-team')]`; +const teamsText = `name.iexact:"${namespace}-team"`; const teamsSearchBadgeCount = '//span[contains(@class, "List-titleBadge") and contains(text(), "1")]'; const teamCheckbox = '//*[@item="team"]//input[@type="checkbox"]'; const addUserToTeam = '//*[@aw-tool-tip="Add User"]'; -const userText = 'username.iexact:"test-actions-user"'; +const userText = `username.iexact:"${namespace}-user"`; const trashButton = '//i[contains(@class, "fa-trash")]'; const deleteButton = '//*[text()="DELETE"]'; @@ -46,12 +48,10 @@ const saveButton = '//*[text()="Save"]'; const addPermission = '//*[@aw-tool-tip="Grant Permission"]'; const addTeamPermission = '//*[@aw-tool-tip="Add a permission"]'; const verifyTeamPermissions = '//*[contains(@class, "List-tableRow")]//*[text()="Read"]'; -const readOrgPermissionResults = '//*[@id="permissions_table"]//*[text()="test-actions-organization"]/parent::*/parent::*//*[contains(text(), "Read")]'; +const readOrgPermissionResults = `//*[@id="permissions_table"]//*[text()="${namespace}-organization"]/parent::*/parent::*//*[contains(text(), "Read")]`; module.exports = { before: (client, done) => { - const namespace = 'test-org-permissions'; - const resources = [ getUserExact(namespace, `${namespace}-user`), getOrganization(namespace), From 13e715aeb92b263fae8517e508779eac0d072e78 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 19 Nov 2018 12:20:42 -0500 Subject: [PATCH 38/42] handle null inventory value on workflow launch --- awx/ui/client/src/templates/prompt/prompt.service.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/src/templates/prompt/prompt.service.js b/awx/ui/client/src/templates/prompt/prompt.service.js index 30b45412d3..150fd994dd 100644 --- a/awx/ui/client/src/templates/prompt/prompt.service.js +++ b/awx/ui/client/src/templates/prompt/prompt.service.js @@ -151,7 +151,7 @@ function PromptService (Empty, $filter) { if (promptData.launchConf.ask_verbosity_on_launch && _.has(promptData, 'prompts.verbosity.value.value')) { launchData.verbosity = promptData.prompts.verbosity.value.value; } - if (promptData.launchConf.ask_inventory_on_launch && !Empty(promptData.prompts.inventory.value.id)){ + if (promptData.launchConf.ask_inventory_on_launch && _.has(promptData, 'prompts.inventory.value.id')) { launchData.inventory_id = promptData.prompts.inventory.value.id; } if (promptData.launchConf.ask_credential_on_launch){ From 951515da2fbed97b4700d6a3991dc9df904524e8 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 19 Nov 2018 15:16:46 -0500 Subject: [PATCH 39/42] disable next and show warning when default workflow inventory is removed --- .../src/templates/prompt/prompt.controller.js | 8 ----- .../src/templates/prompt/prompt.partial.html | 2 +- .../inventory/prompt-inventory.directive.js | 32 ++++++++++++++----- .../inventory/prompt-inventory.partial.html | 3 ++ 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/awx/ui/client/src/templates/prompt/prompt.controller.js b/awx/ui/client/src/templates/prompt/prompt.controller.js index 5377efbecf..82e42ca7eb 100644 --- a/awx/ui/client/src/templates/prompt/prompt.controller.js +++ b/awx/ui/client/src/templates/prompt/prompt.controller.js @@ -15,17 +15,9 @@ export default [ 'Rest', 'GetBasePath', 'ProcessErrors', 'CredentialTypeModel', scope = _scope_; ({ modal } = scope[scope.ns]); - vm.isInventoryOptional = false; - scope.$watch('vm.promptData.triggerModalOpen', () => { - vm.actionButtonClicked = false; if(vm.promptData && vm.promptData.triggerModalOpen) { - - if (vm.promptData.templateType === "workflow_job_template") { - vm.isInventoryOptional = true; - } - scope.$emit('launchModalOpen', true); vm.promptDataClone = _.cloneDeep(vm.promptData); diff --git a/awx/ui/client/src/templates/prompt/prompt.partial.html b/awx/ui/client/src/templates/prompt/prompt.partial.html index 0023ca2c9c..dc3ad632c2 100644 --- a/awx/ui/client/src/templates/prompt/prompt.partial.html +++ b/awx/ui/client/src/templates/prompt/prompt.partial.html @@ -45,7 +45,7 @@
+
+   {{ inventoryWarning }} +
From b74597f4dd3e3bb1902678b51e8dbce64b8adf72 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 19 Nov 2018 15:17:28 -0500 Subject: [PATCH 40/42] fix bug when reverting non-default inventory prompts --- awx/ui/client/src/templates/prompt/prompt.service.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/awx/ui/client/src/templates/prompt/prompt.service.js b/awx/ui/client/src/templates/prompt/prompt.service.js index 150fd994dd..2ac79adaaf 100644 --- a/awx/ui/client/src/templates/prompt/prompt.service.js +++ b/awx/ui/client/src/templates/prompt/prompt.service.js @@ -180,6 +180,17 @@ function PromptService (Empty, $filter) { }); } + if (_.get(promptData, 'templateType') === 'workflow_job_template') { + if (_.get(launchData, 'inventory_id', null) === null) { + // It's possible to get here on a workflow job template with an inventory prompt and no + // default value by selecting an inventory, removing it, selecting a different inventory, + // and then reverting. A null inventory_id may be accepted by the API for prompted workflow + // inventories in the future, but for now they will 400. As such, we intercept that case here + // and remove it from the request data prior to launching. + delete launchData.inventory_id; + } + } + return launchData; }; From 9cd8aa1667ade35b7fb2c418a71ebab60342d3a7 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 19 Nov 2018 15:06:50 -0500 Subject: [PATCH 41/42] further update of workflow docs for inventory feature --- awx/main/models/workflow.py | 2 +- docs/prompting.md | 2 +- docs/workflow.md | 24 ++++++++++++++++++++---- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 1ede7d1b31..2be55d2992 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -190,7 +190,7 @@ class WorkflowJobNode(WorkflowNodeBase): 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 + # Explanation - 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: diff --git a/docs/prompting.md b/docs/prompting.md index 4715b8f65b..a1612657b9 100644 --- a/docs/prompting.md +++ b/docs/prompting.md @@ -180,7 +180,7 @@ job. If a user creates a node that would do this, a 400 response will be returne Workflow JTs are different than other cases, because they do not have a template directly linked, so their prompts are a form of action-at-a-distance. When the node's prompts are gathered, any prompts from the workflow job -can take precedence over the node's value. +will take precedence over the node's value. As a special exception, `extra_vars` from a workflow will not obey JT survey and prompting rules, both both historical and ease-of-understanding reasons. diff --git a/docs/workflow.md b/docs/workflow.md index 6b3b713e14..5d6a7ab42c 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -8,19 +8,35 @@ A workflow has an associated tree-graph that is composed of multiple nodes. Each ### Workflow Create-Read-Update-Delete (CRUD) Like other job resources, workflow jobs are created from workflow job templates. The API exposes common fields similar to job templates, including labels, schedules, notification templates, extra variables and survey specifications. Other than that, in the API, the related workflow graph nodes can be gotten to via the related workflow_nodes field. -The CRUD operations against a workflow job template and its corresponding workflow jobs are almost identical to those of normal job templates and related jobs. However, from an RBAC perspective, CRUD on workflow job templates/jobs are limited to super users. That is, an organization administrator takes full control over all workflow job templates/jobs under the same organization, while an organization auditor is able to see workflow job templates/jobs under the same organization. On the other hand, ordinary organization members have no, and are not able to gain, permission over any workflow-related resources. +The CRUD operations against a workflow job template and its corresponding workflow jobs are almost identical to those of normal job templates and related jobs. However, from an RBAC perspective, CRUD on workflow job templates/jobs are limited to super users. + +By default, organization administrators have full control over all workflow job templates under the same organization, and they share these abilities with users who have the `workflow_admin_role` in that organization. Permissions can be further delegated to other users via the workflow job template roles. ### Workflow Nodes Workflow Nodes are containers of workflow spawned job resources and function as nodes of workflow decision trees. Like that of workflow itself, the two types of workflow nodes are workflow job template nodes and workflow job nodes. Workflow job template nodes are listed and created under endpoint `/workflow_job_templates/\d+/workflow_nodes/` to be associated with underlying workflow job template, or directly under endpoint `/workflow_job_template_nodes/`. The most important fields of a workflow job template node are `success_nodes`, `failure_nodes`, `always_nodes`, `unified_job_template` and `workflow_job_template`. The former three are lists of workflow job template nodes that, in union, forms the set of all its child nodes, in specific, `success_nodes` are triggered when parent node job succeeds, `failure_nodes` are triggered when parent node job fails, and `always_nodes` are triggered regardless of whether parent job succeeds or fails; The later two reference the job template resource it contains and workflow job template it belongs to. -#### Workflow Node Launch Configuration +#### Workflow Launch Configuration + +Workflow job templates can contain launch configuration items. So far, these only include +`extra_vars` and `inventory`, and the `extra_vars` may have specifications via +a survey, in the same way that job templates work. Workflow nodes may also contain the launch-time configuration for the job it will spawn. As such, they share all the properties common to all saved launch configurations. -When a workflow job template is launched a workflow job is created. A workflow job node is created for each WFJT node and all fields from the WFJT node are copied. Note that workflow job nodes contain all fields that a workflow job template node contains plus an additional field, `job`, which is a reference to the to-be-spawned job resource. +When a workflow job template is launched a workflow job is created. If the workflow +job template is set to prompt for a value, then the user may provide this on launch, +and the workflow job will assume the user-provided value. + +A workflow job node is created for each WFJT node and all fields from the WFJT node are copied. Note that workflow job nodes contain all fields that a workflow job template node contains plus an additional field, `job`, which is a reference to the to-be-spawned job resource. + +If the workflow job and the node both specify the same prompt, then the workflow job +takes precedence and its value will be used. In either case, if the job template +the node references does not have the related prompting field set to true +(such as `ask_inventory_on_launch`), then the prompt will be rejected, and the +job template default is used instead. See the document on saved launch configurations for how these are processed when the job is launched, and the API validation involved in building @@ -77,7 +93,7 @@ Other than the normal way of creating workflow job templates, it is also possibl Workflow job templates can be copied by POSTing to endpoint `/workflow_job_templates/\d+/copy/`. After copy finished, the resulting new workflow job template will have identical fields including description, extra_vars, and survey-related fields (survey_spec and survey_enabled). More importantly, workflow job template node of the original workflow job template, as well as the topology they bear, will be copied. Note there are RBAC restrictions on copying workflow job template nodes. A workflow job template is allowed to be copied if the user has permission to add an equivalent workflow job template. If the user performing the copy does not have access to a node's related resources (job template, inventory, or credential), those related fields will be null in the copy's version of the node. Schedules and notification templates of the original workflow job template will not be copied nor shared, and the name of the created workflow job template is the original name plus a special-formatted suffix to indicate its copy origin as well as the copy time, such as 'copy_from_name@10:30:00 am'. -Workflow jobs cannot be copied directly, instead a workflow job is implicitly copied when it needs to relaunch. Relaunching an existing workflow job is done by POSTing to endpoint `/workflow_jobs/\d+/relaunch/`. What happens next is the original workflow job is copied to create a new workflow job. The new workflow job then gets a copy of all nodes of the original as well as the topology they bear. Finally the full-fledged new workflow job is triggered to run, thus fulfilling the purpose of relaunch. Survey password-type answers should also be redacted in the relaunched version of the workflow job. +Workflow jobs cannot be copied directly, instead a workflow job is implicitly copied when it needs to relaunch. Relaunching an existing workflow job is done by POSTing to endpoint `/workflow_jobs/\d+/relaunch/`. What happens next is the original workflow job's prompts are re-applied to its workflow job template to create a new workflow job. Finally the full-fledged new workflow job is triggered to run, thus fulfilling the purpose of relaunch. Survey password-type answers should also be redacted in the relaunched version of the workflow job. ### Artifacts Artifact support starts in Ansible and is carried through in Tower. The `set_stats` module is invoked by users, in a playbook, to register facts. Facts are passed in via `data:` argument. Note that the default `set_stats` parameters are the correct ones to work with Tower (i.e. `per_host: no`). Now that facts are registered, we will describe how facts are used. In Ansible, registered facts are "returned" to the callback plugin(s) via the `playbook_on_stats` event. Ansible users can configure whether or not they want the facts displayed through the global `show_custom_stats` configuration. Note that the `show_custom_stats` does not effect the artifacting feature of Tower. This only controls the displaying of `set_stats` fact data in Ansible output (also the output in Ansible playbooks ran in Tower). Tower uses a custom callback plugin that gathers the fact data set via `set_stats` in the `playbook_on_stats` handler and "ships" it back to Tower, saves it in the database, and makes it available on the job endpoint via the variable `artifacts`. The semantics and usage of `artifacts` throughout a workflow is described elsewhere in this document. From 45728dc1bbf3fd2a76bbf4183f760dde69d26a67 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 19 Nov 2018 15:53:06 -0500 Subject: [PATCH 42/42] update workflow docs --- docs/workflow.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/workflow.md b/docs/workflow.md index 5d6a7ab42c..f27ebce5eb 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -35,8 +35,8 @@ A workflow job node is created for each WFJT node and all fields from the WFJT n If the workflow job and the node both specify the same prompt, then the workflow job takes precedence and its value will be used. In either case, if the job template the node references does not have the related prompting field set to true -(such as `ask_inventory_on_launch`), then the prompt will be rejected, and the -job template default is used instead. +(such as `ask_inventory_on_launch`), then the prompt will be ignored, and the +job template default, if it exists, will be used instead. See the document on saved launch configurations for how these are processed when the job is launched, and the API validation involved in building