Merge pull request #2342 from ansible/workflow_inventory

Workflow level inventory

Reviewed-by: https://github.com/softwarefactory-project-zuul[bot]
This commit is contained in:
softwarefactory-project-zuul[bot] 2018-11-20 03:19:40 +00:00 committed by GitHub
commit fccaebdc8e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 739 additions and 170 deletions

View File

@ -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):

View File

@ -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)

View File

@ -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)

View File

@ -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'),
),
]

View File

@ -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:

View File

@ -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)

View File

@ -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',)

View File

@ -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,

View File

@ -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(

View File

@ -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:

View File

@ -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)

View File

@ -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']

View File

@ -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');

View File

@ -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'];

View File

@ -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'));

View File

@ -93,6 +93,11 @@
ng-show="!vm.isPortalMode && template.summary_fields.user_capabilities.copy"
tooltip="{{:: vm.strings.get('listActions.COPY', vm.getType(template)) }}">
</at-row-action>
<at-row-action icon="fa-sitemap" ng-click="vm.openWorkflowVisualizer(template)"
ng-show="!vm.isPortalMode && template.summary_fields.user_capabilities.edit"
ng-if="template.type === 'workflow_job_template'"
tooltip="{{:: vm.strings.get('list.OPEN_WORKFLOW_VISUALIZER') }}">
</at-row-action>
<at-row-action icon="fa-trash" ng-click="vm.deleteTemplate(template)"
ng-show="!vm.isPortalMode && template.summary_fields.user_capabilities.delete"
tooltip="{{:: vm.strings.get('listActions.DELETE', vm.getType(template)) }}">

View File

@ -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,

View File

@ -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;

View File

@ -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 = {};

View File

@ -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;

View File

@ -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')

View File

@ -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 });
}
};
}

View File

@ -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 += "<tr>\n";
html += "<td><a href=\"\" ng-click=\"viewJob(" + row.id + ")\" " + "aw-tool-tip=\"" + row.status.charAt(0).toUpperCase() + row.status.slice(1) +
html += "<td><a href=\"\" ng-click=\"viewJob(" + row.id + "," + "'" + row.type + "'" + ")\" " + "aw-tool-tip=\"" + row.status.charAt(0).toUpperCase() + row.status.slice(1) +
". Click for details\" aw-tip-placement=\"top\" data-tooltip-outer-class=\"Tooltip-secondary\"><i class=\"fa SmartStatus-tooltip--" + row.status + " icon-job-" + row.status + "\"></i></a></td>\n";
html += "<td>" + ($filter('longDate')(row.finished)) + "</td>";
html += "<td><a href=\"\" ng-click=\"viewJob(" + row.id + ")\" " + "aw-tool-tip=\"" + row.status.charAt(0).toUpperCase() + row.status.slice(1) +
html += "<td><a href=\"\" ng-click=\"viewJob(" + row.id + "," + "'" + row.type + "'" + ")\" " + "aw-tool-tip=\"" + row.status.charAt(0).toUpperCase() + row.status.slice(1) +
". Click for details\" aw-tip-placement=\"top\" data-tooltip-outer-class=\"Tooltip-secondary\">" + $filter('sanitize')(ellipsis(row.name)) + "</a></td>";
html += "</tr>\n";
}

View File

@ -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,

View File

@ -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();

View File

@ -494,6 +494,10 @@ export default ['$compile', 'Attr', 'Icon',
html += `></paginate></div>`;
}
if (options.mode === 'lookup' && options.lookupMessage) {
html = `<div class="Prompt-bodyQuery">${options.lookupMessage}</div>` + html;
}
return html;
},

View File

@ -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 `<lookup-modal>${list_html}</lookup-modal>`;
};
if (field.lookupMessage) {
listConfig.lookupMessage = field.lookupMessage;
}
let list_html = generateList.build(listConfig);
return `<lookup-modal>${list_html}</lookup-modal>`;
}
}
},

View File

@ -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()

View File

@ -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);

View File

@ -45,7 +45,7 @@
<div class="Prompt-footer">
<button id="prompt_cancel" class="Prompt-defaultButton" ng-click="vm.cancel()" ng-show="!vm.readOnlyPrompts">{{:: vm.strings.get('CANCEL') }}</button>
<button id="prompt_close" class="Prompt-defaultButton" ng-click="vm.cancel()" ng-show="vm.readOnlyPrompts">{{:: vm.strings.get('CLOSE') }}</button>
<button id="prompt_inventory_next" class="Prompt-actionButton" ng-show="vm.steps.inventory.tab._active" ng-click="vm.next(vm.steps.inventory.tab)" ng-disabled="!vm.promptDataClone.prompts.inventory.value.id && !vm.readOnlyPrompts">{{:: vm.strings.get('NEXT') }}</button>
<button id="prompt_inventory_next" class="Prompt-actionButton" ng-show="vm.steps.inventory.tab._active" ng-click="vm.next(vm.steps.inventory.tab)" ng-disabled="vm.promptData.templateType === 'workflow_job_template' && !vm.promptDataClone.prompts.inventory.value.id && vm.promptDataClone.launchConf.defaults.inventory.id && !vm.readOnlyPrompts">{{:: vm.strings.get('NEXT') }}</button>
<button id="prompt_credential_next" class="Prompt-actionButton"
ng-show="vm.steps.credential.tab._active"
ng-click="vm.next(vm.steps.credential.tab)"

View File

@ -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){
@ -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;
};
@ -242,28 +253,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;

View File

@ -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,10 +46,31 @@ export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$com
let invList = _.cloneDeep(InventoryList);
invList.disableRow = "{{ readOnlyPrompts }}";
invList.disableRowValue = "readOnlyPrompts";
const defaultWarning = i18n._("This inventory is applied to all job template nodes that prompt for an inventory.");
const missingWarning = i18n._("This workflow job template has a default inventory which must be included or replaced before proceeding.");
const updateInventoryWarning = () => {
scope.inventoryWarning = null;
if (scope.promptData.templateType === "workflow_job_template") {
scope.inventoryWarning = defaultWarning;
const isPrompted = _.get(scope.promptData, 'launchConf.ask_inventory_on_launch');
const isDefault = _.get(scope.promptData, 'launchConf.defaults.inventory.id');
const isSelected = _.get(scope.promptData, 'prompts.inventory.value.id', null) !== null;
if (isPrompted && isDefault && !isSelected) {
scope.inventoryWarning = missingWarning;
}
}
};
updateInventoryWarning();
let html = GenerateList.build({
list: invList,
input_type: 'radio',
mode: 'lookup'
mode: 'lookup',
});
scope.list = invList;
@ -67,6 +88,8 @@ export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$com
else {
scope.inventories[i].checked = 0;
}
updateInventoryWarning();
});
});
});

View File

@ -14,5 +14,8 @@
</div>
</div>
</div>
<div ng-if="inventoryWarning" class="Prompt-credentialTypeMissing">
<span class="fa fa-warning"></span>&nbsp; {{ inventoryWarning }}
</div>
<div id="prompt-inventory"></div>
</div>

View File

@ -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: "<p>" + i18n._("Select an inventory for the workflow. This inventory is applied to all job template nodes that prompt for an inventory.") + "</p>",
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',

View File

@ -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 });
});
});
});

View File

@ -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;

View File

@ -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

View File

@ -133,6 +133,8 @@
</select>
</div>
</div>
<div ng-show="editNodeHelpMessage" class="WorkflowMaker-formHelp" ng-bind="editNodeHelpMessage"></div>
<br />
<div class="buttons Form-buttons" id="workflow_maker_controls">
<button type="button" class="btn btn-sm Form-primaryButton Form-primaryButton--noMargin" id="workflow_maker_prompt_btn" ng-show="showPromptButton" ng-click="openPromptModal()"> {{:: strings.get('prompt.PROMPT') }}</button>
<button type="button" class="btn btn-sm Form-cancelButton" id="workflow_maker_cancel_btn" ng-show="(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)" ng-click="cancelNodeForm()"> {{:: strings.get('CANCEL') }}</button>

View File

@ -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'),

View File

@ -125,6 +125,21 @@
</div>
</div>
<!-- INVENTORY DETAIL -->
<div class="WorkflowResults-resultRow"
ng-show="workflow.summary_fields.inventory">
<label class="WorkflowResults-resultRowLabel">
{{ strings.labels.INVENTORY }}
</label>
<div class="WorkflowResults-resultRowText">
<a href="{{ inventory_link }}"
aw-tool-tip="{{ strings.tooltips.EDIT_WORKFLOW }}"
data-placement="top">
{{ workflow.summary_fields.inventory.name }}
</a>
</div>
</div>
<!-- TEMPLATE DETAIL -->
<div class="WorkflowResults-resultRow"
ng-show="workflow.summary_fields.workflow_job_template.name">

View File

@ -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)

View File

@ -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()', () => {

View File

@ -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

View File

@ -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.