diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 531c6a869b..eba6bbcf4e 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) @@ -3726,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): @@ -4417,37 +4418,63 @@ 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) + 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', - '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_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) def validate(self, attrs): - obj = self.instance + template = self.instance - accepted, rejected, errors = obj._accept_or_ignore_job_kwargs( - _exclude_errors=['required'], - **attrs) + accepted, rejected, errors = template._accept_or_ignore_job_kwargs(**attrs) + self._ignored_fields = rejected - WFJT_extra_vars = obj.extra_vars - attrs = super(WorkflowJobLaunchSerializer, self).validate(attrs) - obj.extra_vars = WFJT_extra_vars - return attrs + 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) + + WFJT_extra_vars = template.extra_vars + WFJT_inventory = template.inventory + super(WorkflowJobLaunchSerializer, self).validate(attrs) + template.extra_vars = WFJT_extra_vars + template.inventory = WFJT_inventory + return accepted class NotificationTemplateSerializer(BaseSerializer): diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 84b2ebc151..87140b02b1 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3106,23 +3106,31 @@ 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 + else: + data.pop('inventory', None) return data 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) - prompted_fields, ignored_fields, errors = obj._accept_or_ignore_job_kwargs(**request.data) + if not request.user.can_access(JobLaunchConfig, 'add', serializer.validated_data, template=obj): + raise PermissionDenied() - 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/access.py b/awx/main/access.py index 43d2ed08a0..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 @@ -1949,19 +1954,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/migrations/0053_v340_workflow_inventory.py b/awx/main/migrations/0053_v340_workflow_inventory.py new file mode 100644 index 0000000000..285b4262fe --- /dev/null +++ b/awx/main/migrations/0053_v340_workflow_inventory.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-09-27 19:50 +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', '0052_v340_remove_project_scm_delete_on_next_update'), + ] + + 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, 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..64d7811028 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') @@ -895,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', @@ -916,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( @@ -934,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) @@ -946,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: @@ -959,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): @@ -994,7 +985,42 @@ 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(): + if field_name == 'extra_vars': + continue try: LaunchTimeConfig._meta.get_field(field_name) except FieldDoesNotExist: diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 7dcb560aa2..d6dbc3e4fa 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -301,14 +301,22 @@ 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) # 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=', '.join(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) diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 1f1d776bd4..2be55d2992 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -24,14 +24,14 @@ 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, 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) + # 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: + # 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, @@ -290,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): @@ -342,6 +353,19 @@ 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, + ) admin_role = ImplicitRoleField(parent_role=[ 'singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, 'organization.workflow_admin_role' @@ -396,27 +420,45 @@ 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 = {} - 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) + continue + + 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 @@ -446,7 +488,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',) 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, 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( 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_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) 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'] 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'); diff --git a/awx/ui/client/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index c22d1805f7..0efcff23c4 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -12,6 +12,7 @@ function TemplatesStrings (BaseString) { PANEL_TITLE: t.s('TEMPLATES'), ADD_DD_JT_LABEL: t.s('Job Template'), ADD_DD_WF_LABEL: t.s('Workflow Template'), + OPEN_WORKFLOW_VISUALIZER: t.s('Click here to open the workflow visualizer'), ROW_ITEM_LABEL_ACTIVITY: t.s('Activity'), ROW_ITEM_LABEL_INVENTORY: t.s('Inventory'), ROW_ITEM_LABEL_PROJECT: t.s('Project'), @@ -116,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/features/templates/templatesList.controller.js b/awx/ui/client/features/templates/templatesList.controller.js index 26b477b23d..7bf634b1db 100644 --- a/awx/ui/client/features/templates/templatesList.controller.js +++ b/awx/ui/client/features/templates/templatesList.controller.js @@ -101,6 +101,14 @@ function ListTemplatesController( vm.isPortalMode = $state.includes('portalMode'); + vm.openWorkflowVisualizer = template => { + const name = 'templates.editWorkflowJobTemplate.workflowMaker'; + const params = { workflow_job_template_id: template.id }; + const options = { reload: true }; + + $state.go(name, params, options); + }; + vm.deleteTemplate = template => { if (!template) { Alert(strings.get('error.DELETE'), strings.get('alert.MISSING_PARAMETER')); diff --git a/awx/ui/client/features/templates/templatesList.view.html b/awx/ui/client/features/templates/templatesList.view.html index 255efcc3cc..e3b9317f28 100644 --- a/awx/ui/client/features/templates/templatesList.view.html +++ b/awx/ui/client/features/templates/templatesList.view.html @@ -93,6 +93,11 @@ ng-show="!vm.isPortalMode && template.summary_fields.user_capabilities.copy" tooltip="{{:: vm.strings.get('listActions.COPY', vm.getType(template)) }}"> + + diff --git a/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js b/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js index 20cf1d8e94..4574ef6fc5 100644 --- a/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js +++ b/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js @@ -93,16 +93,15 @@ 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: 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/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/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 79198bbe82..ae649c03fb 100644 --- a/awx/ui/client/lib/models/WorkflowJobTemplate.js +++ b/awx/ui/client/lib/models/WorkflowJobTemplate.js @@ -1,3 +1,4 @@ +/* eslint camelcase: 0 */ let Base; let $http; @@ -46,12 +47,19 @@ function getSurveyQuestions (id) { return $http(req); } +function getLaunchConf () { + return this.model.launch.GET; +} + function canLaunchWithoutPrompt () { - const launchData = this.model.launch.GET; + const launchData = this.getLaunchConf(); return ( launchData.can_start_without_user_input && - !launchData.survey_enabled + !launchData.ask_inventory_on_launch && + !launchData.ask_variables_on_launch && + !launchData.survey_enabled && + launchData.variables_needed_to_start.length === 0 ); } @@ -63,6 +71,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 = {}; @@ -79,7 +88,7 @@ function WorkflowJobTemplateModelLoader (BaseModel, _$http_) { WorkflowJobTemplateModelLoader.$inject = [ 'BaseModel', - '$http' + '$http', ]; export default WorkflowJobTemplateModelLoader; 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') 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"; } diff --git a/awx/ui/client/src/scheduler/schedulerAdd.controller.js b/awx/ui/client/src/scheduler/schedulerAdd.controller.js index e3fdda845f..a9e0b9eff7 100644 --- a/awx/ui/client/src/scheduler/schedulerAdd.controller.js +++ b/awx/ui/client/src/scheduler/schedulerAdd.controller.js @@ -239,7 +239,19 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait', }); }; - if(!launchConf.survey_enabled) { + 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.survey_enabled && + !launchConf.credential_needed_to_start && + !launchConf.inventory_needed_to_start && + launchConf.variables_needed_to_start.length === 0) { $scope.showPromptButton = false; } else { $scope.showPromptButton = true; @@ -259,6 +271,7 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait', launchConf: responses[1].data, launchOptions: responses[0].data, surveyQuestions: processed.surveyQuestions, + templateType: ParentObject.type, template: ParentObject.id, prompts: PromptService.processPromptValues({ launchConf: responses[1].data, @@ -283,6 +296,7 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait', $scope.promptData = { launchConf: responses[1].data, launchOptions: responses[0].data, + templateType: ParentObject.type, template: ParentObject.id, prompts: PromptService.processPromptValues({ launchConf: responses[1].data, diff --git a/awx/ui/client/src/scheduler/schedulerEdit.controller.js b/awx/ui/client/src/scheduler/schedulerEdit.controller.js index 0e27408c1f..75ed397e7b 100644 --- a/awx/ui/client/src/scheduler/schedulerEdit.controller.js +++ b/awx/ui/client/src/scheduler/schedulerEdit.controller.js @@ -424,7 +424,20 @@ function($filter, $state, $stateParams, Wait, $scope, moment, currentValues: scheduleResolve }); - if(!launchConf.survey_enabled) { + 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.survey_enabled && + !launchConf.credential_needed_to_start && + !launchConf.inventory_needed_to_start && + launchConf.passwords_needed_to_start.length === 0 && + launchConf.variables_needed_to_start.length === 0) { $scope.showPromptButton = false; } else { $scope.showPromptButton = true; @@ -446,6 +459,7 @@ function($filter, $state, $stateParams, Wait, $scope, moment, launchOptions: launchOptions, prompts: prompts, surveyQuestions: surveyQuestionRes.data.spec, + templateType: ParentObject.type, template: ParentObject.id }; @@ -467,6 +481,7 @@ function($filter, $state, $stateParams, Wait, $scope, moment, launchConf: launchConf, launchOptions: launchOptions, prompts: prompts, + templateType: ParentObject.type, template: ParentObject.id }; watchForPromptChanges(); diff --git a/awx/ui/client/src/shared/list-generator/list-generator.factory.js b/awx/ui/client/src/shared/list-generator/list-generator.factory.js index 92fb28cf71..ef4f2d68c0 100644 --- a/awx/ui/client/src/shared/list-generator/list-generator.factory.js +++ b/awx/ui/client/src/shared/list-generator/list-generator.factory.js @@ -494,6 +494,10 @@ export default ['$compile', 'Attr', 'Icon', html += `>`; } + 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/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/prompt/prompt.controller.js b/awx/ui/client/src/templates/prompt/prompt.controller.js index 1c5c33a6c5..82e42ca7eb 100644 --- a/awx/ui/client/src/templates/prompt/prompt.controller.js +++ b/awx/ui/client/src/templates/prompt/prompt.controller.js @@ -16,10 +16,8 @@ export default [ 'Rest', 'GetBasePath', 'ProcessErrors', 'CredentialTypeModel', ({ modal } = scope[scope.ns]); scope.$watch('vm.promptData.triggerModalOpen', () => { - vm.actionButtonClicked = false; if(vm.promptData && vm.promptData.triggerModalOpen) { - 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..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 }} +
diff --git a/awx/ui/client/src/templates/workflows.form.js b/awx/ui/client/src/templates/workflows.form.js index 50a3ee867c..eece22f6fe 100644 --- a/awx/ui/client/src/templates/workflows.form.js +++ b/awx/ui/client/src/templates/workflows.form.js @@ -68,6 +68,27 @@ 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', + 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 template nodes that prompt for an inventory.") + "

", + 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: { 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..72d02d89e7 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 @@ -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 @@ -68,6 +69,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); @@ -152,8 +154,7 @@ export default [ $q.all(defers) .then(function() { // If we follow the same pattern as job templates then the survey logic will go here - - $state.go('templates.editWorkflowJobTemplate', {workflow_job_template_id: data.data.id}, {reload: true}); + $state.go('templates.editWorkflowJobTemplate.workflowMaker', { workflow_job_template_id: data.data.id }, { reload: 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 dc6a35d402..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 @@ -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; @@ -53,6 +53,12 @@ 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; + $scope.inventory_name = Inventory.name; + } $scope.openWorkflowMaker = function() { $state.go('.workflowMaker'); @@ -83,6 +89,8 @@ export default [ } } + data.ask_inventory_on_launch = Boolean($scope.ask_inventory_on_launch); + data.extra_vars = ToJSON($scope.parseType, $scope.variables, true); @@ -312,6 +320,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; 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..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 @@ -6,10 +6,10 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', 'ProcessErrors', 'CreateSelect2', '$q', 'JobTemplateModel', 'WorkflowJobTemplateModel', - 'Empty', 'PromptService', 'Rest', 'TemplatesStrings', '$timeout', + 'Empty', 'PromptService', 'Rest', 'TemplatesStrings', '$timeout', '$state', function ($scope, WorkflowService, TemplatesService, ProcessErrors, CreateSelect2, $q, JobTemplate, WorkflowJobTemplate, - Empty, PromptService, Rest, TemplatesStrings, $timeout) { + Empty, PromptService, Rest, TemplatesStrings, $timeout, $state) { let promptWatcher, surveyQuestionWatcher, credentialsWatcher; @@ -409,6 +409,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', return $q.all(associatePromises.concat(credentialPromises)) .then(function () { $scope.closeDialog(); + $state.transitionTo('templates'); }); }).catch(({ data, @@ -432,6 +433,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', $q.all(deletePromises) .then(function () { $scope.closeDialog(); + $state.transitionTo('templates'); }); } }; @@ -568,6 +570,7 @@ 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) { @@ -749,6 +752,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', launchOptions: launchOptions, prompts: prompts, surveyQuestions: surveyQuestionRes.data.spec, + templateType: $scope.nodeBeingEdited.unifiedJobTemplate.type, template: $scope.nodeBeingEdited.unifiedJobTemplate.id }; @@ -771,6 +775,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', launchConf: launchConf, launchOptions: launchOptions, prompts: prompts, + templateType: $scope.nodeBeingEdited.unifiedJobTemplate.type, template: $scope.nodeBeingEdited.unifiedJobTemplate.id }; @@ -985,6 +990,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) { @@ -1000,12 +1041,15 @@ 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(); $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 +1066,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,12 +1092,12 @@ 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, + templateType: selectedTemplate.type, prompts: PromptService.processPromptValues({ launchConf: responses[1].data, launchOptions: responses[0].data @@ -1084,10 +1117,12 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', watchForPromptChanges(); }); } else { + $scope.promptData = { - launchConf: responses[1].data, + launchConf, launchOptions: responses[0].data, template: selectedTemplate.id, + templateType: selectedTemplate.type, prompts: PromptService.processPromptValues({ launchConf: responses[1].data, launchOptions: responses[0].data 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..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,6 +133,8 @@ +
+
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 @@
+ +
+ + +
+
diff --git a/awx/ui/test/e2e/tests/test-org-permissions.js b/awx/ui/test/e2e/tests/test-org-permissions.js index 4db48e8b23..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,14 +48,14 @@ 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 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/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()', () => { diff --git a/docs/prompting.md b/docs/prompting.md index 2a2047839e..a1612657b9 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 +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. +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 diff --git a/docs/workflow.md b/docs/workflow.md index 6b3b713e14..f27ebce5eb 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 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 @@ -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.