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
44 changed files with 739 additions and 170 deletions

View File

@@ -3599,7 +3599,7 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo
class Meta: class Meta:
model = WorkflowJobTemplate model = WorkflowJobTemplate
fields = ('*', 'extra_vars', 'organization', 'survey_enabled', 'allow_simultaneous', 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): def get_related(self, obj):
res = super(WorkflowJobTemplateSerializer, self).get_related(obj) res = super(WorkflowJobTemplateSerializer, self).get_related(obj)
@@ -3643,7 +3643,8 @@ class WorkflowJobSerializer(LabelsListMixin, UnifiedJobSerializer):
model = WorkflowJob model = WorkflowJob
fields = ('*', 'workflow_job_template', 'extra_vars', 'allow_simultaneous', fields = ('*', 'workflow_job_template', 'extra_vars', 'allow_simultaneous',
'job_template', 'is_sliced_job', '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): def get_related(self, obj):
res = super(WorkflowJobSerializer, self).get_related(obj) res = super(WorkflowJobSerializer, self).get_related(obj)
@@ -3726,7 +3727,7 @@ class LaunchConfigurationBaseSerializer(BaseSerializer):
if obj is None: if obj is None:
return ret return ret
if 'extra_data' in ret and obj.survey_passwords: 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 return ret
def get_summary_fields(self, obj): def get_summary_fields(self, obj):
@@ -4417,37 +4418,63 @@ class JobLaunchSerializer(BaseSerializer):
class WorkflowJobLaunchSerializer(BaseSerializer): class WorkflowJobLaunchSerializer(BaseSerializer):
can_start_without_user_input = serializers.BooleanField(read_only=True) can_start_without_user_input = serializers.BooleanField(read_only=True)
defaults = serializers.SerializerMethodField()
variables_needed_to_start = serializers.ReadOnlyField() variables_needed_to_start = serializers.ReadOnlyField()
survey_enabled = serializers.SerializerMethodField() survey_enabled = serializers.SerializerMethodField()
extra_vars = VerbatimField(required=False, write_only=True) 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() workflow_job_template_data = serializers.SerializerMethodField()
class Meta: class Meta:
model = WorkflowJobTemplate model = WorkflowJobTemplate
fields = ('can_start_without_user_input', 'extra_vars', fields = ('ask_inventory_on_launch', 'can_start_without_user_input', 'defaults', 'extra_vars',
'survey_enabled', 'variables_needed_to_start', 'inventory', 'survey_enabled', 'variables_needed_to_start',
'node_templates_missing', 'node_prompts_rejected', '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): def get_survey_enabled(self, obj):
if obj: if obj:
return obj.survey_enabled and 'spec' in obj.survey_spec return obj.survey_enabled and 'spec' in obj.survey_spec
return False 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): def get_workflow_job_template_data(self, obj):
return dict(name=obj.name, id=obj.id, description=obj.description) return dict(name=obj.name, id=obj.id, description=obj.description)
def validate(self, attrs): def validate(self, attrs):
obj = self.instance template = self.instance
accepted, rejected, errors = obj._accept_or_ignore_job_kwargs( accepted, rejected, errors = template._accept_or_ignore_job_kwargs(**attrs)
_exclude_errors=['required'], self._ignored_fields = rejected
**attrs)
WFJT_extra_vars = obj.extra_vars if template.inventory and template.inventory.pending_deletion is True:
attrs = super(WorkflowJobLaunchSerializer, self).validate(attrs) errors['inventory'] = _("The inventory associated with this Workflow is being deleted.")
obj.extra_vars = WFJT_extra_vars elif 'inventory' in accepted and accepted['inventory'].pending_deletion:
return attrs 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): class NotificationTemplateSerializer(BaseSerializer):

View File

@@ -3106,23 +3106,31 @@ class WorkflowJobTemplateLaunch(WorkflowsEnforcementMixin, RetrieveAPIView):
extra_vars.setdefault(v, u'') extra_vars.setdefault(v, u'')
if extra_vars: if extra_vars:
data['extra_vars'] = 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 return data
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
obj = self.get_object() 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) serializer = self.serializer_class(instance=obj, data=request.data)
if not serializer.is_valid(): if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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() new_job.signal_start()
data = OrderedDict() data = OrderedDict()
data['workflow_job'] = new_job.id 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)) data.update(WorkflowJobSerializer(new_job, context=self.get_serializer_context()).to_representation(new_job))
headers = {'Location': new_job.get_absolute_url(request)} headers = {'Location': new_job.get_absolute_url(request)}
return Response(data, status=status.HTTP_201_CREATED, headers=headers) 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']: if 'survey_enabled' in data and data['survey_enabled']:
self.check_license(feature='surveys') self.check_license(feature='surveys')
return self.check_related('organization', Organization, data, role_field='workflow_admin_role', return (
mandatory=True) 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): def can_copy(self, obj):
if self.save_messages: if self.save_messages:
@@ -1890,8 +1892,11 @@ class WorkflowJobTemplateAccess(BaseAccess):
if self.user.is_superuser: if self.user.is_superuser:
return True return True
return (self.check_related('organization', Organization, data, role_field='workflow_admin_role', obj=obj) and return (
self.user in obj.admin_role) 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): def can_delete(self, obj):
return self.user.is_superuser or self.user in obj.admin_role return self.user.is_superuser or self.user in obj.admin_role
@@ -1949,19 +1954,29 @@ class WorkflowJobAccess(BaseAccess):
if not template: if not template:
return False return False
# If job was launched by another user, it could have survey passwords # Obtain prompts used to start original job
if obj.created_by_id != self.user.pk: JobLaunchConfig = obj._meta.get_field('launch_config').related_model
# Obtain prompts used to start original job try:
JobLaunchConfig = obj._meta.get_field('launch_config').related_model config = JobLaunchConfig.objects.get(job=obj)
try: except JobLaunchConfig.DoesNotExist:
config = JobLaunchConfig.objects.get(job=obj) if self.save_messages:
except JobLaunchConfig.DoesNotExist: self.messages['detail'] = _('Workflow Job was launched with unknown prompts.')
config = None 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: if self.save_messages:
self.messages['detail'] = _('Job was launched with prompts provided by another user.') self.messages['detail'] = _('Job was launched with prompts provided by another user.')
return False 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 # execute permission to WFJT is mandatory for any relaunch
return (self.user in template.execute_role) 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, JobNotificationMixin,
) )
from awx.main.utils import parse_yaml_or_json, getattr_dne 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 ( from awx.main.models.mixins import (
ResourceMixin, ResourceMixin,
SurveyJobTemplateMixin, SurveyJobTemplateMixin,
@@ -43,7 +43,6 @@ from awx.main.models.mixins import (
CustomVirtualEnvMixin, CustomVirtualEnvMixin,
RelatedJobsMixin, RelatedJobsMixin,
) )
from awx.main.fields import JSONField, AskForField
logger = logging.getLogger('awx.main.models.jobs') logger = logging.getLogger('awx.main.models.jobs')
@@ -895,19 +894,19 @@ class NullablePromptPsuedoField(object):
instance.char_prompts[self.field_name] = value 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 Needed as separate class from LaunchTimeConfig because some models
WFJT / WJ nodes, schedules, and job launch configs (not all implemented yet) 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: class Meta:
abstract = True abstract = True
# Prompting-related fields that have to be handled as special cases # Prompting-related fields that have to be handled as special cases
credentials = models.ManyToManyField(
'Credential',
related_name='%(class)ss'
)
inventory = models.ForeignKey( inventory = models.ForeignKey(
'Inventory', 'Inventory',
related_name='%(class)ss', related_name='%(class)ss',
@@ -916,15 +915,6 @@ class LaunchTimeConfig(BaseModel):
default=None, default=None,
on_delete=models.SET_NULL, 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 # All standard fields are stored in this dictionary field
# This is a solution to the nullable CharField problem, specific to prompting # This is a solution to the nullable CharField problem, specific to prompting
char_prompts = JSONField( char_prompts = JSONField(
@@ -934,6 +924,7 @@ class LaunchTimeConfig(BaseModel):
def prompts_dict(self, display=False): def prompts_dict(self, display=False):
data = {} data = {}
# Some types may have different prompts, but always subset of JT prompts
for prompt_name in JobTemplate.get_ask_mapping().keys(): for prompt_name in JobTemplate.get_ask_mapping().keys():
try: try:
field = self._meta.get_field(prompt_name) field = self._meta.get_field(prompt_name)
@@ -946,11 +937,11 @@ class LaunchTimeConfig(BaseModel):
if len(prompt_val) > 0: if len(prompt_val) > 0:
data[prompt_name] = prompt_val data[prompt_name] = prompt_val
elif prompt_name == 'extra_vars': elif prompt_name == 'extra_vars':
if self.extra_data: if self.extra_vars:
if display: if display:
data[prompt_name] = self.display_extra_data() data[prompt_name] = self.display_extra_vars()
else: else:
data[prompt_name] = self.extra_data data[prompt_name] = self.extra_vars
if self.survey_passwords and not display: if self.survey_passwords and not display:
data['survey_passwords'] = self.survey_passwords data['survey_passwords'] = self.survey_passwords
else: else:
@@ -959,18 +950,18 @@ class LaunchTimeConfig(BaseModel):
data[prompt_name] = prompt_val data[prompt_name] = prompt_val
return data return data
def display_extra_data(self): def display_extra_vars(self):
''' '''
Hides fields marked as passwords in survey. Hides fields marked as passwords in survey.
''' '''
if self.survey_passwords: 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(): for key, value in self.survey_passwords.items():
if key in extra_data: if key in extra_vars:
extra_data[key] = value extra_vars[key] = value
return extra_data return extra_vars
else: else:
return self.extra_data return self.extra_vars
@property @property
def _credential(self): def _credential(self):
@@ -994,7 +985,42 @@ class LaunchTimeConfig(BaseModel):
return None 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(): for field_name in JobTemplate.get_ask_mapping().keys():
if field_name == 'extra_vars':
continue
try: try:
LaunchTimeConfig._meta.get_field(field_name) LaunchTimeConfig._meta.get_field(field_name)
except FieldDoesNotExist: except FieldDoesNotExist:

View File

@@ -301,14 +301,22 @@ class SurveyJobTemplateMixin(models.Model):
accepted.update(extra_vars) accepted.update(extra_vars)
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: if extra_vars:
# Leftover extra_vars, keys provided that are not allowed # Leftover extra_vars, keys provided that are not allowed
rejected.update(extra_vars) rejected.update(extra_vars)
# ignored variables does not block manual launch # ignored variables does not block manual launch
if 'prompts' not in _exclude_errors: if 'prompts' not in _exclude_errors:
errors['extra_vars'] = [_('Variables {list_of_keys} are not allowed on launch. Check the Prompt on Launch setting '+ 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( 'on the {model_name} to include Extra Variables.').format(
list_of_keys=', '.join(extra_vars.keys()))] list_of_keys=six.text_type(', ').join([six.text_type(key) for key in extra_vars.keys()]),
model_name=self._meta.verbose_name.title())]
return (accepted, rejected, errors) return (accepted, rejected, errors)

View File

@@ -24,14 +24,14 @@ from awx.main.models.rbac import (
ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
ROLE_SINGLETON_SYSTEM_AUDITOR ROLE_SINGLETON_SYSTEM_AUDITOR
) )
from awx.main.fields import ImplicitRoleField from awx.main.fields import ImplicitRoleField, AskForField
from awx.main.models.mixins import ( from awx.main.models.mixins import (
ResourceMixin, ResourceMixin,
SurveyJobTemplateMixin, SurveyJobTemplateMixin,
SurveyJobMixin, SurveyJobMixin,
RelatedJobsMixin, 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.models.credential import Credential
from awx.main.redact import REPLACE_STR from awx.main.redact import REPLACE_STR
from awx.main.fields import JSONField from awx.main.fields import JSONField
@@ -188,6 +188,16 @@ class WorkflowJobNode(WorkflowNodeBase):
def get_absolute_url(self, request=None): def get_absolute_url(self, request=None):
return reverse('api:workflow_job_node_detail', kwargs={'pk': self.pk}, request=request) 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): def get_job_kwargs(self):
''' '''
In advance of creating a new unified job as part of a workflow, In advance of creating a new unified job as part of a workflow,
@@ -290,7 +300,8 @@ class WorkflowJobOptions(BaseModel):
@classmethod @classmethod
def _get_unified_job_field_names(cls): def _get_unified_job_field_names(cls):
return set(f.name for f in WorkflowJobOptions._meta.fields) | set( 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): def _create_workflow_nodes(self, old_node_list, user=None):
@@ -342,6 +353,19 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name='workflows', 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=[ admin_role = ImplicitRoleField(parent_role=[
'singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, 'singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
'organization.workflow_admin_role' 'organization.workflow_admin_role'
@@ -396,27 +420,45 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
workflow_job.copy_nodes_from_original(original=self) workflow_job.copy_nodes_from_original(original=self)
return workflow_job 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', []) exclude_errors = kwargs.pop('_exclude_errors', [])
prompted_data = {} prompted_data = {}
rejected_data = {} rejected_data = {}
accepted_vars, rejected_vars, errors_dict = self.accept_or_ignore_variables( errors_dict = {}
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
# WFJTs do not behave like JTs, it can not accept inventory, credential, etc. # Handle all the fields that have prompting rules
bad_kwargs = kwargs.copy() # NOTE: If WFJTs prompt for other things, this logic can be combined with jobs
bad_kwargs.pop('extra_vars', None) for field_name, ask_field_name in self.get_ask_mapping().items():
bad_kwargs.pop('survey_passwords', None)
if bad_kwargs: if field_name == 'extra_vars':
rejected_data.update(bad_kwargs) accepted_vars, rejected_vars, vars_errors = self.accept_or_ignore_variables(
for field in bad_kwargs: kwargs.get('extra_vars', {}),
errors_dict[field] = _('Field is not allowed for use in workflows.') _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 return prompted_data, rejected_data, errors_dict
@@ -446,7 +488,7 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
return WorkflowJob.objects.filter(workflow_job_template=self) return WorkflowJob.objects.filter(workflow_job_template=self)
class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin): class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin, LaunchTimeConfigBase):
class Meta: class Meta:
app_label = 'main' app_label = 'main'
ordering = ('id',) ordering = ('id',)

View File

@@ -6,7 +6,7 @@ import pytest
# AWX # AWX
from awx.api.serializers import JobTemplateSerializer from awx.api.serializers import JobTemplateSerializer
from awx.api.versioning import reverse 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 from awx.main.migrations import _save_password_keys as save_password_keys
# Django # 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.'] 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 @pytest.mark.django_db
def test_launch_with_extra_credentials(get, post, organization_factory, def test_launch_with_extra_credentials(get, post, organization_factory,
job_template_factory, machine_credential, 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) 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 @pytest.mark.django_db
def test_valid_survey_answer(post, admin_user, project, inventory, survey_spec_factory): def test_valid_survey_answer(post, admin_user, project, inventory, survey_spec_factory):
job_template = JobTemplate.objects.create( job_template = JobTemplate.objects.create(

View File

@@ -149,6 +149,20 @@ class TestWorkflowJobAccess:
wfjt.execute_role.members.add(alice) wfjt.execute_role.members.add(alice)
assert not WorkflowJobAccess(rando).can_start(workflow_job) 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 @pytest.mark.django_db
class TestWFJTCopyAccess: class TestWFJTCopyAccess:

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
import tempfile import tempfile
import json import json
import yaml import yaml
@@ -10,7 +11,9 @@ from awx.main.models import (
Job, Job,
JobTemplate, JobTemplate,
JobLaunchConfig, JobLaunchConfig,
WorkflowJobTemplate WorkflowJobTemplate,
Project,
Inventory
) )
from awx.main.utils.safe_yaml import SafeLoader from awx.main.utils.safe_yaml import SafeLoader
@@ -305,3 +308,49 @@ class TestWorkflowSurveys:
) )
assert wfjt.variables_needed_to_start == ['question2'] assert wfjt.variables_needed_to_start == ['question2']
assert not wfjt.can_start_without_user_input() 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(): 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, { const searchParam = _.assign($stateParams.job_search, {
or__job__inventory: inventoryId, or__job__inventory: inventoryId,
or__adhoccommand__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'); const searchPath = GetBasePath('unified_jobs');

View File

@@ -12,6 +12,7 @@ function TemplatesStrings (BaseString) {
PANEL_TITLE: t.s('TEMPLATES'), PANEL_TITLE: t.s('TEMPLATES'),
ADD_DD_JT_LABEL: t.s('Job Template'), ADD_DD_JT_LABEL: t.s('Job Template'),
ADD_DD_WF_LABEL: t.s('Workflow 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_ACTIVITY: t.s('Activity'),
ROW_ITEM_LABEL_INVENTORY: t.s('Inventory'), ROW_ITEM_LABEL_INVENTORY: t.s('Inventory'),
ROW_ITEM_LABEL_PROJECT: t.s('Project'), ROW_ITEM_LABEL_PROJECT: t.s('Project'),
@@ -116,9 +117,12 @@ function TemplatesStrings (BaseString) {
DELETED: t.s('DELETED'), DELETED: t.s('DELETED'),
START: t.s('START'), START: t.s('START'),
DETAILS: t.s('DETAILS'), 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']; TemplatesStrings.$inject = ['BaseStringService'];

View File

@@ -101,6 +101,14 @@ function ListTemplatesController(
vm.isPortalMode = $state.includes('portalMode'); 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 => { vm.deleteTemplate = template => {
if (!template) { if (!template) {
Alert(strings.get('error.DELETE'), strings.get('alert.MISSING_PARAMETER')); 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" ng-show="!vm.isPortalMode && template.summary_fields.user_capabilities.copy"
tooltip="{{:: vm.strings.get('listActions.COPY', vm.getType(template)) }}"> tooltip="{{:: vm.strings.get('listActions.COPY', vm.getType(template)) }}">
</at-row-action> </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)" <at-row-action icon="fa-trash" ng-click="vm.deleteTemplate(template)"
ng-show="!vm.isPortalMode && template.summary_fields.user_capabilities.delete" ng-show="!vm.isPortalMode && template.summary_fields.user_capabilities.delete"
tooltip="{{:: vm.strings.get('listActions.DELETE', vm.getType(template)) }}"> 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 }); $state.go('workflowResults', { id: data.workflow_job }, { reload: true });
}); });
} else { } else {
launchData.data.defaults = { launchData.data.defaults.extra_vars = wfjtData.data.extra_vars;
extra_vars: wfjtData.data.extra_vars
};
const promptData = { const promptData = {
launchConf: launchData.data, launchConf: selectedWorkflowJobTemplate.getLaunchConf(),
launchOptions: launchOptions.data, launchOptions: launchOptions.data,
template: vm.template.id, template: vm.template.id,
templateType: vm.template.type, templateType: vm.template.type,
prompts: PromptService.processPromptValues({ prompts: PromptService.processPromptValues({
launchConf: launchData.data, launchConf: selectedWorkflowJobTemplate.getLaunchConf(),
launchOptions: launchOptions.data launchOptions: launchOptions.data
}), }),
triggerModalOpen: true, triggerModalOpen: true,

View File

@@ -1,5 +1,6 @@
let Base; let Base;
let JobTemplate; let JobTemplate;
let WorkflowJobTemplate;
function setDependentResources (id) { function setDependentResources (id) {
this.dependentResources = [ this.dependentResources = [
@@ -8,6 +9,12 @@ function setDependentResources (id) {
params: { params: {
inventory: id inventory: id
} }
},
{
model: new WorkflowJobTemplate(),
params: {
inventory: id
}
} }
]; ];
} }
@@ -21,16 +28,18 @@ function InventoryModel (method, resource, config) {
return this.create(method, resource, config); return this.create(method, resource, config);
} }
function InventoryModelLoader (BaseModel, JobTemplateModel) { function InventoryModelLoader (BaseModel, JobTemplateModel, WorkflowJobTemplateModel) {
Base = BaseModel; Base = BaseModel;
JobTemplate = JobTemplateModel; JobTemplate = JobTemplateModel;
WorkflowJobTemplate = WorkflowJobTemplateModel;
return InventoryModel; return InventoryModel;
} }
InventoryModelLoader.$inject = [ InventoryModelLoader.$inject = [
'BaseModel', 'BaseModel',
'JobTemplateModel' 'JobTemplateModel',
'WorkflowJobTemplateModel',
]; ];
export default InventoryModelLoader; export default InventoryModelLoader;

View File

@@ -47,8 +47,15 @@ function getSurveyQuestions (id) {
return $http(req); 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 () { function canLaunchWithoutPrompt () {
const launchData = this.model.launch.GET; const launchData = this.getLaunchConf();
return ( return (
launchData.can_start_without_user_input && launchData.can_start_without_user_input &&
@@ -61,7 +68,8 @@ function canLaunchWithoutPrompt () {
!launchData.ask_skip_tags_on_launch && !launchData.ask_skip_tags_on_launch &&
!launchData.ask_variables_on_launch && !launchData.ask_variables_on_launch &&
!launchData.ask_diff_mode_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.getLaunch = getLaunch.bind(this);
this.postLaunch = postLaunch.bind(this); this.postLaunch = postLaunch.bind(this);
this.getSurveyQuestions = getSurveyQuestions.bind(this); this.getSurveyQuestions = getSurveyQuestions.bind(this);
this.getLaunchConf = getLaunchConf.bind(this);
this.canLaunchWithoutPrompt = canLaunchWithoutPrompt.bind(this); this.canLaunchWithoutPrompt = canLaunchWithoutPrompt.bind(this);
this.model.launch = {}; this.model.launch = {};

View File

@@ -1,3 +1,4 @@
/* eslint camelcase: 0 */
let Base; let Base;
let $http; let $http;
@@ -46,12 +47,19 @@ function getSurveyQuestions (id) {
return $http(req); return $http(req);
} }
function getLaunchConf () {
return this.model.launch.GET;
}
function canLaunchWithoutPrompt () { function canLaunchWithoutPrompt () {
const launchData = this.model.launch.GET; const launchData = this.getLaunchConf();
return ( return (
launchData.can_start_without_user_input && 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.getLaunch = getLaunch.bind(this);
this.postLaunch = postLaunch.bind(this); this.postLaunch = postLaunch.bind(this);
this.getSurveyQuestions = getSurveyQuestions.bind(this); this.getSurveyQuestions = getSurveyQuestions.bind(this);
this.getLaunchConf = getLaunchConf.bind(this);
this.canLaunchWithoutPrompt = canLaunchWithoutPrompt.bind(this); this.canLaunchWithoutPrompt = canLaunchWithoutPrompt.bind(this);
this.model.launch = {}; this.model.launch = {};
@@ -79,7 +88,7 @@ function WorkflowJobTemplateModelLoader (BaseModel, _$http_) {
WorkflowJobTemplateModelLoader.$inject = [ WorkflowJobTemplateModelLoader.$inject = [
'BaseModel', 'BaseModel',
'$http' '$http',
]; ];
export default WorkflowJobTemplateModelLoader; 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 = { ns.workflow_job_template_nodes = {
LABEL: t.s('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 (!Empty($scope.inventory.id)) {
if ($scope.inventory.total_hosts > 0) { if ($scope.inventory.total_hosts > 0) {
Wait('start'); 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"; url += "&order_by=-finished&page_size=5";
Rest.setUrl(url); Rest.setUrl(url);
Rest.get() Rest.get()
.then(({data}) => { .then(({data}) => {
@@ -22,8 +26,14 @@ export default [ '$scope', 'Empty', 'Wait', 'GetBasePath', 'Rest', 'ProcessError
} }
}; };
$scope.viewJob = function(jobId) { $scope.viewJob = function(jobId, type) {
$state.go('output', { id: jobId, type: 'playbook' }); 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) { data.results.forEach(function(row) {
if ((scope.inventory.has_active_failures && row.status === 'failed') || (!scope.inventory.has_active_failures && row.status === 'successful')) { if ((scope.inventory.has_active_failures && row.status === 'failed') || (!scope.inventory.has_active_failures && row.status === 'successful')) {
html += "<tr>\n"; 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"; ". 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>" + ($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>"; ". Click for details\" aw-tip-placement=\"top\" data-tooltip-outer-class=\"Tooltip-secondary\">" + $filter('sanitize')(ellipsis(row.name)) + "</a></td>";
html += "</tr>\n"; 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; $scope.showPromptButton = false;
} else { } else {
$scope.showPromptButton = true; $scope.showPromptButton = true;
@@ -259,6 +271,7 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait',
launchConf: responses[1].data, launchConf: responses[1].data,
launchOptions: responses[0].data, launchOptions: responses[0].data,
surveyQuestions: processed.surveyQuestions, surveyQuestions: processed.surveyQuestions,
templateType: ParentObject.type,
template: ParentObject.id, template: ParentObject.id,
prompts: PromptService.processPromptValues({ prompts: PromptService.processPromptValues({
launchConf: responses[1].data, launchConf: responses[1].data,
@@ -283,6 +296,7 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait',
$scope.promptData = { $scope.promptData = {
launchConf: responses[1].data, launchConf: responses[1].data,
launchOptions: responses[0].data, launchOptions: responses[0].data,
templateType: ParentObject.type,
template: ParentObject.id, template: ParentObject.id,
prompts: PromptService.processPromptValues({ prompts: PromptService.processPromptValues({
launchConf: responses[1].data, launchConf: responses[1].data,

View File

@@ -424,7 +424,20 @@ function($filter, $state, $stateParams, Wait, $scope, moment,
currentValues: scheduleResolve 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; $scope.showPromptButton = false;
} else { } else {
$scope.showPromptButton = true; $scope.showPromptButton = true;
@@ -446,6 +459,7 @@ function($filter, $state, $stateParams, Wait, $scope, moment,
launchOptions: launchOptions, launchOptions: launchOptions,
prompts: prompts, prompts: prompts,
surveyQuestions: surveyQuestionRes.data.spec, surveyQuestions: surveyQuestionRes.data.spec,
templateType: ParentObject.type,
template: ParentObject.id template: ParentObject.id
}; };
@@ -467,6 +481,7 @@ function($filter, $state, $stateParams, Wait, $scope, moment,
launchConf: launchConf, launchConf: launchConf,
launchOptions: launchOptions, launchOptions: launchOptions,
prompts: prompts, prompts: prompts,
templateType: ParentObject.type,
template: ParentObject.id template: ParentObject.id
}; };
watchForPromptChanges(); watchForPromptChanges();

View File

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

View File

@@ -806,13 +806,19 @@ function($injector, $stateExtender, $log, i18n) {
views: { views: {
'modal': { 'modal': {
templateProvider: function(ListDefinition, generateList) { templateProvider: function(ListDefinition, generateList) {
let list_html = generateList.build({ const listConfig = {
mode: 'lookup', mode: 'lookup',
list: ListDefinition, list: ListDefinition,
input_type: 'radio' 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: { resolve: {
add: { 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', availableLabels: ['Rest', '$stateParams', 'GetBasePath', 'ProcessErrors', 'TemplatesService',
function(Rest, $stateParams, GetBasePath, ProcessErrors, TemplatesService) { function(Rest, $stateParams, GetBasePath, ProcessErrors, TemplatesService) {
return TemplatesService.getAllLabelOptions() return TemplatesService.getAllLabelOptions()
@@ -354,6 +371,23 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p
}, },
resolve: { resolve: {
edit: { 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', availableLabels: ['Rest', '$stateParams', 'GetBasePath', 'ProcessErrors', 'TemplatesService',
function(Rest, $stateParams, GetBasePath, ProcessErrors, TemplatesService) { function(Rest, $stateParams, GetBasePath, ProcessErrors, TemplatesService) {
return TemplatesService.getAllLabelOptions() return TemplatesService.getAllLabelOptions()

View File

@@ -16,10 +16,8 @@ export default [ 'Rest', 'GetBasePath', 'ProcessErrors', 'CredentialTypeModel',
({ modal } = scope[scope.ns]); ({ modal } = scope[scope.ns]);
scope.$watch('vm.promptData.triggerModalOpen', () => { scope.$watch('vm.promptData.triggerModalOpen', () => {
vm.actionButtonClicked = false; vm.actionButtonClicked = false;
if(vm.promptData && vm.promptData.triggerModalOpen) { if(vm.promptData && vm.promptData.triggerModalOpen) {
scope.$emit('launchModalOpen', true); scope.$emit('launchModalOpen', true);
vm.promptDataClone = _.cloneDeep(vm.promptData); vm.promptDataClone = _.cloneDeep(vm.promptData);

View File

@@ -45,7 +45,7 @@
<div class="Prompt-footer"> <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_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_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" <button id="prompt_credential_next" class="Prompt-actionButton"
ng-show="vm.steps.credential.tab._active" ng-show="vm.steps.credential.tab._active"
ng-click="vm.next(vm.steps.credential.tab)" 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')) { if (promptData.launchConf.ask_verbosity_on_launch && _.has(promptData, 'prompts.verbosity.value.value')) {
launchData.verbosity = 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; launchData.inventory_id = promptData.prompts.inventory.value.id;
} }
if (promptData.launchConf.ask_credential_on_launch){ 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; 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')) { 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')){ 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(); 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')){ 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(); 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')){ 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')){ 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')){ 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')){ 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; return promptDataToSave;

View File

@@ -6,8 +6,8 @@
import promptInventoryController from './prompt-inventory.controller'; import promptInventoryController from './prompt-inventory.controller';
export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$compile', 'InventoryList', export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$compile', 'InventoryList', 'i18n',
(templateUrl, qs, GetBasePath, GenerateList, $compile, InventoryList) => { (templateUrl, qs, GetBasePath, GenerateList, $compile, InventoryList, i18n) => {
return { return {
scope: { scope: {
promptData: '=', promptData: '=',
@@ -46,10 +46,31 @@ export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$com
let invList = _.cloneDeep(InventoryList); let invList = _.cloneDeep(InventoryList);
invList.disableRow = "{{ readOnlyPrompts }}"; invList.disableRow = "{{ readOnlyPrompts }}";
invList.disableRowValue = "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({ let html = GenerateList.build({
list: invList, list: invList,
input_type: 'radio', input_type: 'radio',
mode: 'lookup' mode: 'lookup',
}); });
scope.list = invList; scope.list = invList;
@@ -67,6 +88,8 @@ export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$com
else { else {
scope.inventories[i].checked = 0; scope.inventories[i].checked = 0;
} }
updateInventoryWarning();
}); });
}); });
}); });

View File

@@ -14,5 +14,8 @@
</div> </div>
</div> </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 id="prompt-inventory"></div>
</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', ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate) || !canEditOrg',
awLookupWhen: '(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: { labels: {
label: i18n._('Labels'), label: i18n._('Labels'),
type: 'select', type: 'select',

View File

@@ -23,6 +23,7 @@ export default [
$scope.canAddWorkflowJobTemplate = workflowTemplate.options('actions.POST'); $scope.canAddWorkflowJobTemplate = workflowTemplate.options('actions.POST');
$scope.canEditOrg = true; $scope.canEditOrg = true;
$scope.canEditInventory = true;
$scope.parseType = 'yaml'; $scope.parseType = 'yaml';
$scope.can_edit = true; $scope.can_edit = true;
// apply form definition's default field values // apply form definition's default field values
@@ -68,6 +69,7 @@ export default [
data[fld] = $scope[fld]; data[fld] = $scope[fld];
} }
} }
data.ask_inventory_on_launch = Boolean($scope.ask_inventory_on_launch);
data.extra_vars = ToJSON($scope.parseType, data.extra_vars = ToJSON($scope.parseType,
$scope.variables, true); $scope.variables, true);
@@ -152,8 +154,7 @@ export default [
$q.all(defers) $q.all(defers)
.then(function() { .then(function() {
// If we follow the same pattern as job templates then the survey logic will go here // If we follow the same pattern as job templates then the survey logic will go here
$state.go('templates.editWorkflowJobTemplate.workflowMaker', { workflow_job_template_id: data.data.id }, { reload: true });
$state.go('templates.editWorkflowJobTemplate', {workflow_job_template_id: data.data.id}, {reload: true});
}); });
}); });
}); });

View File

@@ -10,12 +10,12 @@ export default [
'Wait', 'Empty', 'ToJSON', 'initSurvey', '$state', 'CreateSelect2', 'Wait', 'Empty', 'ToJSON', 'initSurvey', '$state', 'CreateSelect2',
'ParseVariableString', 'TemplatesService', 'Rest', 'ToggleNotification', 'ParseVariableString', 'TemplatesService', 'Rest', 'ToggleNotification',
'OrgAdminLookup', 'availableLabels', 'selectedLabels', 'workflowJobTemplateData', 'i18n', 'OrgAdminLookup', 'availableLabels', 'selectedLabels', 'workflowJobTemplateData', 'i18n',
'workflowLaunch', '$transitions', 'WorkflowJobTemplateModel', 'workflowLaunch', '$transitions', 'WorkflowJobTemplateModel', 'Inventory',
function($scope, $stateParams, WorkflowForm, GenerateForm, Alert, function($scope, $stateParams, WorkflowForm, GenerateForm, Alert,
ProcessErrors, GetBasePath, $q, ParseTypeChange, Wait, Empty, ProcessErrors, GetBasePath, $q, ParseTypeChange, Wait, Empty,
ToJSON, SurveyControllerInit, $state, CreateSelect2, ParseVariableString, ToJSON, SurveyControllerInit, $state, CreateSelect2, ParseVariableString,
TemplatesService, Rest, ToggleNotification, OrgAdminLookup, availableLabels, selectedLabels, workflowJobTemplateData, i18n, 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; $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.mode = 'edit';
$scope.parseType = 'yaml'; $scope.parseType = 'yaml';
$scope.includeWorkflowMaker = false; $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() { $scope.openWorkflowMaker = function() {
$state.go('.workflowMaker'); $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, data.extra_vars = ToJSON($scope.parseType,
$scope.variables, true); $scope.variables, true);
@@ -312,6 +320,16 @@ export default [
$scope.canEditOrg = true; $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.url = workflowJobTemplateData.url;
$scope.survey_enabled = workflowJobTemplateData.survey_enabled; $scope.survey_enabled = workflowJobTemplateData.survey_enabled;

View File

@@ -6,10 +6,10 @@
export default ['$scope', 'WorkflowService', 'TemplatesService', export default ['$scope', 'WorkflowService', 'TemplatesService',
'ProcessErrors', 'CreateSelect2', '$q', 'JobTemplateModel', 'WorkflowJobTemplateModel', 'ProcessErrors', 'CreateSelect2', '$q', 'JobTemplateModel', 'WorkflowJobTemplateModel',
'Empty', 'PromptService', 'Rest', 'TemplatesStrings', '$timeout', 'Empty', 'PromptService', 'Rest', 'TemplatesStrings', '$timeout', '$state',
function ($scope, WorkflowService, TemplatesService, function ($scope, WorkflowService, TemplatesService,
ProcessErrors, CreateSelect2, $q, JobTemplate, WorkflowJobTemplate, ProcessErrors, CreateSelect2, $q, JobTemplate, WorkflowJobTemplate,
Empty, PromptService, Rest, TemplatesStrings, $timeout) { Empty, PromptService, Rest, TemplatesStrings, $timeout, $state) {
let promptWatcher, surveyQuestionWatcher, credentialsWatcher; let promptWatcher, surveyQuestionWatcher, credentialsWatcher;
@@ -409,6 +409,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
return $q.all(associatePromises.concat(credentialPromises)) return $q.all(associatePromises.concat(credentialPromises))
.then(function () { .then(function () {
$scope.closeDialog(); $scope.closeDialog();
$state.transitionTo('templates');
}); });
}).catch(({ }).catch(({
data, data,
@@ -432,6 +433,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
$q.all(deletePromises) $q.all(deletePromises)
.then(function () { .then(function () {
$scope.closeDialog(); $scope.closeDialog();
$state.transitionTo('templates');
}); });
} }
}; };
@@ -568,6 +570,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
/* EDIT NODE FUNCTIONS */ /* EDIT NODE FUNCTIONS */
$scope.startEditNode = function (nodeToEdit) { $scope.startEditNode = function (nodeToEdit) {
$scope.editNodeHelpMessage = null;
if (!$scope.nodeBeingEdited || ($scope.nodeBeingEdited && $scope.nodeBeingEdited.id !== nodeToEdit.id)) { if (!$scope.nodeBeingEdited || ($scope.nodeBeingEdited && $scope.nodeBeingEdited.id !== nodeToEdit.id)) {
if ($scope.placeholderNode || $scope.nodeBeingEdited) { if ($scope.placeholderNode || $scope.nodeBeingEdited) {
@@ -749,6 +752,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
launchOptions: launchOptions, launchOptions: launchOptions,
prompts: prompts, prompts: prompts,
surveyQuestions: surveyQuestionRes.data.spec, surveyQuestions: surveyQuestionRes.data.spec,
templateType: $scope.nodeBeingEdited.unifiedJobTemplate.type,
template: $scope.nodeBeingEdited.unifiedJobTemplate.id template: $scope.nodeBeingEdited.unifiedJobTemplate.id
}; };
@@ -771,6 +775,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
launchConf: launchConf, launchConf: launchConf,
launchOptions: launchOptions, launchOptions: launchOptions,
prompts: prompts, prompts: prompts,
templateType: $scope.nodeBeingEdited.unifiedJobTemplate.type,
template: $scope.nodeBeingEdited.unifiedJobTemplate.id 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) { $scope.templateManuallySelected = function (selectedTemplate) {
if (promptWatcher) { if (promptWatcher) {
@@ -1000,12 +1041,15 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
} }
$scope.promptData = null; $scope.promptData = null;
$scope.editNodeHelpMessage = getEditNodeHelpMessage($scope.treeData.workflow_job_template_obj, selectedTemplate);
if (selectedTemplate.type === "job_template" || selectedTemplate.type === "workflow_job_template") { if (selectedTemplate.type === "job_template" || selectedTemplate.type === "workflow_job_template") {
let jobTemplate = selectedTemplate.type === "workflow_job_template" ? new WorkflowJobTemplate() : new JobTemplate(); let jobTemplate = selectedTemplate.type === "workflow_job_template" ? new WorkflowJobTemplate() : new JobTemplate();
$q.all([jobTemplate.optionsLaunch(selectedTemplate.id), jobTemplate.getLaunch(selectedTemplate.id)]) $q.all([jobTemplate.optionsLaunch(selectedTemplate.id), jobTemplate.getLaunch(selectedTemplate.id)])
.then((responses) => { .then((responses) => {
let launchConf = responses[1].data; const launchConf = jobTemplate.getLaunchConf();
if (selectedTemplate.type === 'job_template') { if (selectedTemplate.type === 'job_template') {
if ((!selectedTemplate.inventory && !launchConf.ask_inventory_on_launch) || !selectedTemplate.project) { if ((!selectedTemplate.inventory && !launchConf.ask_inventory_on_launch) || !selectedTemplate.project) {
$scope.selectedTemplateInvalid = true; $scope.selectedTemplateInvalid = true;
@@ -1022,24 +1066,13 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
$scope.selectedTemplate = angular.copy(selectedTemplate); $scope.selectedTemplate = angular.copy(selectedTemplate);
if (!launchConf.survey_enabled && if (jobTemplate.canLaunchWithoutPrompt()) {
!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) {
$scope.showPromptButton = false; $scope.showPromptButton = false;
$scope.promptModalMissingReqFields = false; $scope.promptModalMissingReqFields = false;
} else { } else {
$scope.showPromptButton = true; $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')) { if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory')) {
$scope.promptModalMissingReqFields = true; $scope.promptModalMissingReqFields = true;
} else { } else {
@@ -1059,12 +1092,12 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
}); });
$scope.missingSurveyValue = processed.missingSurveyValue; $scope.missingSurveyValue = processed.missingSurveyValue;
$scope.promptData = { $scope.promptData = {
launchConf: responses[1].data, launchConf,
launchOptions: responses[0].data, launchOptions: responses[0].data,
surveyQuestions: processed.surveyQuestions, surveyQuestions: processed.surveyQuestions,
template: selectedTemplate.id, template: selectedTemplate.id,
templateType: selectedTemplate.type,
prompts: PromptService.processPromptValues({ prompts: PromptService.processPromptValues({
launchConf: responses[1].data, launchConf: responses[1].data,
launchOptions: responses[0].data launchOptions: responses[0].data
@@ -1084,10 +1117,12 @@ export default ['$scope', 'WorkflowService', 'TemplatesService',
watchForPromptChanges(); watchForPromptChanges();
}); });
} else { } else {
$scope.promptData = { $scope.promptData = {
launchConf: responses[1].data, launchConf,
launchOptions: responses[0].data, launchOptions: responses[0].data,
template: selectedTemplate.id, template: selectedTemplate.id,
templateType: selectedTemplate.type,
prompts: PromptService.processPromptValues({ prompts: PromptService.processPromptValues({
launchConf: responses[1].data, launchConf: responses[1].data,
launchOptions: responses[0].data launchOptions: responses[0].data

View File

@@ -133,6 +133,8 @@
</select> </select>
</div> </div>
</div> </div>
<div ng-show="editNodeHelpMessage" class="WorkflowMaker-formHelp" ng-bind="editNodeHelpMessage"></div>
<br />
<div class="buttons Form-buttons" id="workflow_maker_controls"> <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-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> <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.cloud_credential_link = getLink('cloud_credential');
$scope.network_credential_link = getLink('network_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 = { $scope.strings = {
tooltips: { tooltips: {
RELAUNCH: i18n._('Relaunch using the same parameters'), RELAUNCH: i18n._('Relaunch using the same parameters'),
@@ -54,7 +62,8 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions',
STATUS: i18n._('Status'), STATUS: i18n._('Status'),
SLICE_TEMPLATE: i18n._('Slice Job Template'), SLICE_TEMPLATE: i18n._('Slice Job Template'),
JOB_EXPLANATION: i18n._('Explanation'), JOB_EXPLANATION: i18n._('Explanation'),
SOURCE_WORKFLOW_JOB: i18n._('Source Workflow') SOURCE_WORKFLOW_JOB: i18n._('Source Workflow'),
INVENTORY: i18n._('Inventory')
}, },
details: { details: {
HEADER: i18n._('DETAILS'), HEADER: i18n._('DETAILS'),

View File

@@ -125,6 +125,21 @@
</div> </div>
</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 --> <!-- TEMPLATE DETAIL -->
<div class="WorkflowResults-resultRow" <div class="WorkflowResults-resultRow"
ng-show="workflow.summary_fields.workflow_job_template.name"> ng-show="workflow.summary_fields.workflow_job_template.name">

View File

@@ -4,6 +4,8 @@ import {
getTeam, getTeam,
} from '../fixtures'; } from '../fixtures';
const namespace = 'test-org-permissions';
let data; let data;
const spinny = "//*[contains(@class, 'spinny')]"; const spinny = "//*[contains(@class, 'spinny')]";
const checkbox = '//input[@type="checkbox"]'; const checkbox = '//input[@type="checkbox"]';
@@ -23,7 +25,7 @@ const teamsTab = '//*[@id="teams_tab"]';
const permissionsTab = '//*[@id="permissions_tab"]'; const permissionsTab = '//*[@id="permissions_tab"]';
const usersTab = '//*[@id="users_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 orgsCheckbox = '//select-list-item[@item="organization"]//input[@type="checkbox"]';
const orgDetails = '//*[contains(@class, "OrgCards-label")]'; const orgDetails = '//*[contains(@class, "OrgCards-label")]';
const orgRoleSelector = '//*[contains(@aria-labelledby, "select2-organizations")]'; const orgRoleSelector = '//*[contains(@aria-labelledby, "select2-organizations")]';
@@ -32,12 +34,12 @@ const readRole = '//*[contains(@id, "organizations-role") and text()="Read"]';
const memberRoleText = 'member'; const memberRoleText = 'member';
const readRoleText = 'read'; const readRoleText = 'read';
const teamsSelector = "//a[contains(text(), 'test-actions-team')]"; const teamsSelector = `//a[contains(text(), '${namespace}-team')]`;
const teamsText = 'name.iexact:"test-actions-team"'; const teamsText = `name.iexact:"${namespace}-team"`;
const teamsSearchBadgeCount = '//span[contains(@class, "List-titleBadge") and contains(text(), "1")]'; const teamsSearchBadgeCount = '//span[contains(@class, "List-titleBadge") and contains(text(), "1")]';
const teamCheckbox = '//*[@item="team"]//input[@type="checkbox"]'; const teamCheckbox = '//*[@item="team"]//input[@type="checkbox"]';
const addUserToTeam = '//*[@aw-tool-tip="Add User"]'; 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 trashButton = '//i[contains(@class, "fa-trash")]';
const deleteButton = '//*[text()="DELETE"]'; const deleteButton = '//*[text()="DELETE"]';
@@ -46,14 +48,14 @@ const saveButton = '//*[text()="Save"]';
const addPermission = '//*[@aw-tool-tip="Grant Permission"]'; const addPermission = '//*[@aw-tool-tip="Grant Permission"]';
const addTeamPermission = '//*[@aw-tool-tip="Add a permission"]'; const addTeamPermission = '//*[@aw-tool-tip="Add a permission"]';
const verifyTeamPermissions = '//*[contains(@class, "List-tableRow")]//*[text()="Read"]'; 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 = { module.exports = {
before: (client, done) => { before: (client, done) => {
const resources = [ const resources = [
getUserExact('test-actions', 'test-actions-user'), getUserExact(namespace, `${namespace}-user`),
getOrganization('test-actions'), getOrganization(namespace),
getTeam('test-actions'), getTeam(namespace),
]; ];
Promise.all(resources) Promise.all(resources)

View File

@@ -142,14 +142,15 @@ describe('Controller: WorkflowAdd', () => {
expect(TemplatesService.createWorkflowJobTemplate).toHaveBeenCalledWith({ expect(TemplatesService.createWorkflowJobTemplate).toHaveBeenCalledWith({
name: "Test Workflow", name: "Test Workflow",
description: "This is a test description", description: "This is a test description",
labels: undefined,
organization: undefined, organization: undefined,
inventory: undefined,
labels: undefined,
variables: undefined, variables: undefined,
extra_vars: undefined, allow_simultaneous: undefined,
allow_simultaneous: undefined ask_inventory_on_launch: false,
extra_vars: undefined
}); });
}); });
}); });
describe('scope.formCancel()', () => { describe('scope.formCancel()', () => {

View File

@@ -64,7 +64,7 @@ actions in the API.
- POST to `/api/v2/job_templates/N/launch/` - POST to `/api/v2/job_templates/N/launch/`
- can accept all prompt-able fields - can accept all prompt-able fields
- POST to `/api/v2/workflow_job_templates/N/launch/` - 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/` - POST to `/api/v2/system_job_templates/N/launch/`
- can accept certain fields, with no user configuration - can accept certain fields, with no user configuration
@@ -142,6 +142,7 @@ at launch-time that are saved in advance.
- Workflow nodes - Workflow nodes
- Schedules - Schedules
- Job relaunch / re-scheduling - Job relaunch / re-scheduling
- (partially) workflow job templates
In the case of workflow nodes and schedules, the prompted fields are saved In the case of workflow nodes and schedules, the prompted fields are saved
directly on the model. Those models include Workflow Job Template Nodes, 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 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/`. 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 nodes will combine `extra_vars` from their parent
workflow job with the variables that they provide in 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 All prompts that a workflow node passes to a spawned job abides by the
rules of the related template. rules of the related template.
That means that if the node's job template has `ask_variables_on_launch` set 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 to false with no survey, the workflow node's variables will not
in the job that is spawned. take effect in the job that is spawned.
If the node's job template has `ask_inventory_on_launch` set to false and 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 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. 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 Job Template Prompts
workflow variables to the jobs it spawned, allowing variables to take effect
even when the job template was not configured to allow it. 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 #### 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) ### 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. 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
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 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 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. 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. 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 See the document on saved launch configurations for how these are processed
when the job is launched, and the API validation involved in building 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 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 ### 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. 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.