Merge pull request #4369 from AlanCoding/workflow_limit

Implementation of WFJT limit & SCM_branch prompting

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2019-09-16 20:17:59 +00:00
committed by GitHub
22 changed files with 414 additions and 102 deletions

View File

@@ -3314,11 +3314,14 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo
'admin', 'execute', 'admin', 'execute',
{'copy': 'organization.workflow_admin'} {'copy': 'organization.workflow_admin'}
] ]
limit = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
scm_branch = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
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', 'inventory', 'ask_inventory_on_launch',) 'ask_variables_on_launch', 'inventory', 'limit', 'scm_branch',
'ask_inventory_on_launch', 'ask_scm_branch_on_launch', 'ask_limit_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)
@@ -3344,6 +3347,22 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo
def validate_extra_vars(self, value): def validate_extra_vars(self, value):
return vars_validate_or_raise(value) return vars_validate_or_raise(value)
def validate(self, attrs):
attrs = super(WorkflowJobTemplateSerializer, self).validate(attrs)
# process char_prompts, these are not direct fields on the model
mock_obj = self.Meta.model()
for field_name in ('scm_branch', 'limit'):
if field_name in attrs:
setattr(mock_obj, field_name, attrs[field_name])
attrs.pop(field_name)
# Model `.save` needs the container dict, not the pseudo fields
if mock_obj.char_prompts:
attrs['char_prompts'] = mock_obj.char_prompts
return attrs
class WorkflowJobTemplateWithSpecSerializer(WorkflowJobTemplateSerializer): class WorkflowJobTemplateWithSpecSerializer(WorkflowJobTemplateSerializer):
''' '''
@@ -3356,13 +3375,15 @@ class WorkflowJobTemplateWithSpecSerializer(WorkflowJobTemplateSerializer):
class WorkflowJobSerializer(LabelsListMixin, UnifiedJobSerializer): class WorkflowJobSerializer(LabelsListMixin, UnifiedJobSerializer):
limit = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
scm_branch = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
class Meta: class Meta:
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',) 'inventory', 'limit', 'scm_branch',)
def get_related(self, obj): def get_related(self, obj):
res = super(WorkflowJobSerializer, self).get_related(obj) res = super(WorkflowJobSerializer, self).get_related(obj)
@@ -3596,7 +3617,7 @@ class LaunchConfigurationBaseSerializer(BaseSerializer):
if errors: if errors:
raise serializers.ValidationError(errors) raise serializers.ValidationError(errors)
# Model `.save` needs the container dict, not the psuedo fields # Model `.save` needs the container dict, not the pseudo fields
if mock_obj.char_prompts: if mock_obj.char_prompts:
attrs['char_prompts'] = mock_obj.char_prompts attrs['char_prompts'] = mock_obj.char_prompts
@@ -4180,12 +4201,16 @@ class WorkflowJobLaunchSerializer(BaseSerializer):
queryset=Inventory.objects.all(), queryset=Inventory.objects.all(),
required=False, write_only=True required=False, write_only=True
) )
limit = serializers.CharField(required=False, write_only=True, allow_blank=True)
scm_branch = serializers.CharField(required=False, write_only=True, allow_blank=True)
workflow_job_template_data = serializers.SerializerMethodField() workflow_job_template_data = serializers.SerializerMethodField()
class Meta: class Meta:
model = WorkflowJobTemplate model = WorkflowJobTemplate
fields = ('ask_inventory_on_launch', 'can_start_without_user_input', 'defaults', 'extra_vars', fields = ('ask_inventory_on_launch', 'ask_limit_on_launch', 'ask_scm_branch_on_launch',
'inventory', 'survey_enabled', 'variables_needed_to_start', 'can_start_without_user_input', 'defaults', 'extra_vars',
'inventory', 'limit', 'scm_branch',
'survey_enabled', 'variables_needed_to_start',
'node_templates_missing', 'node_prompts_rejected', 'node_templates_missing', 'node_prompts_rejected',
'workflow_job_template_data', 'survey_enabled', 'ask_variables_on_launch') 'workflow_job_template_data', 'survey_enabled', 'ask_variables_on_launch')
read_only_fields = ('ask_inventory_on_launch', 'ask_variables_on_launch') read_only_fields = ('ask_inventory_on_launch', 'ask_variables_on_launch')
@@ -4225,9 +4250,14 @@ class WorkflowJobLaunchSerializer(BaseSerializer):
WFJT_extra_vars = template.extra_vars WFJT_extra_vars = template.extra_vars
WFJT_inventory = template.inventory WFJT_inventory = template.inventory
WFJT_limit = template.limit
WFJT_scm_branch = template.scm_branch
super(WorkflowJobLaunchSerializer, self).validate(attrs) super(WorkflowJobLaunchSerializer, self).validate(attrs)
template.extra_vars = WFJT_extra_vars template.extra_vars = WFJT_extra_vars
template.inventory = WFJT_inventory template.inventory = WFJT_inventory
template.limit = WFJT_limit
template.scm_branch = WFJT_scm_branch
return accepted return accepted

View File

@@ -3111,6 +3111,17 @@ class WorkflowJobTemplateCopy(CopyAPIView):
data.update(messages) data.update(messages)
return Response(data) return Response(data)
def _build_create_dict(self, obj):
"""Special processing of fields managed by char_prompts
"""
r = super(WorkflowJobTemplateCopy, self)._build_create_dict(obj)
field_names = set(f.name for f in obj._meta.get_fields())
for field_name, ask_field_name in obj.get_ask_mapping().items():
if field_name in r and field_name not in field_names:
r.setdefault('char_prompts', {})
r['char_prompts'][field_name] = r.pop(field_name)
return r
@staticmethod @staticmethod
def deep_copy_permission_check_func(user, new_objs): def deep_copy_permission_check_func(user, new_objs):
for obj in new_objs: for obj in new_objs:
@@ -3139,7 +3150,6 @@ class WorkflowJobTemplateLabelList(JobTemplateLabelList):
class WorkflowJobTemplateLaunch(RetrieveAPIView): class WorkflowJobTemplateLaunch(RetrieveAPIView):
model = models.WorkflowJobTemplate model = models.WorkflowJobTemplate
obj_permission_type = 'start' obj_permission_type = 'start'
serializer_class = serializers.WorkflowJobLaunchSerializer serializer_class = serializers.WorkflowJobLaunchSerializer
@@ -3156,10 +3166,15 @@ class WorkflowJobTemplateLaunch(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: modified_ask_mapping = models.WorkflowJobTemplate.get_ask_mapping()
data['inventory'] = obj.inventory_id modified_ask_mapping.pop('extra_vars')
else: for field_name, ask_field_name in obj.get_ask_mapping().items():
data.pop('inventory', None) if not getattr(obj, ask_field_name):
data.pop(field_name, None)
elif field_name == 'inventory':
data[field_name] = getattrd(obj, "%s.%s" % (field_name, 'id'), None)
else:
data[field_name] = getattr(obj, field_name)
return data return data
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):

View File

@@ -0,0 +1,59 @@
# Generated by Django 2.2.2 on 2019-07-23 17:56
import awx.main.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0089_v360_new_job_event_types'),
]
operations = [
migrations.AddField(
model_name='workflowjobtemplate',
name='ask_limit_on_launch',
field=awx.main.fields.AskForField(blank=True, default=False),
),
migrations.AddField(
model_name='workflowjobtemplate',
name='ask_scm_branch_on_launch',
field=awx.main.fields.AskForField(blank=True, default=False),
),
migrations.AddField(
model_name='workflowjobtemplate',
name='char_prompts',
field=awx.main.fields.JSONField(blank=True, default=dict),
),
migrations.AlterField(
model_name='joblaunchconfig',
name='inventory',
field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied as a prompt, assuming job template prompts for inventory', null=True, on_delete=models.deletion.SET_NULL, related_name='joblaunchconfigs', to='main.Inventory'),
),
migrations.AlterField(
model_name='schedule',
name='inventory',
field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied as a prompt, assuming job template prompts for inventory', null=True, on_delete=models.deletion.SET_NULL, related_name='schedules', to='main.Inventory'),
),
migrations.AlterField(
model_name='workflowjob',
name='inventory',
field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied as a prompt, assuming job template prompts for inventory', null=True, on_delete=models.deletion.SET_NULL, related_name='workflowjobs', to='main.Inventory'),
),
migrations.AlterField(
model_name='workflowjobnode',
name='inventory',
field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied as a prompt, assuming job template prompts for inventory', null=True, on_delete=models.deletion.SET_NULL, related_name='workflowjobnodes', to='main.Inventory'),
),
migrations.AlterField(
model_name='workflowjobtemplate',
name='inventory',
field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied as a prompt, assuming job template prompts for inventory', null=True, on_delete=models.deletion.SET_NULL, related_name='workflowjobtemplates', to='main.Inventory'),
),
migrations.AlterField(
model_name='workflowjobtemplatenode',
name='inventory',
field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied as a prompt, assuming job template prompts for inventory', null=True, on_delete=models.deletion.SET_NULL, related_name='workflowjobtemplatenodes', to='main.Inventory'),
),
]

View File

@@ -1501,7 +1501,7 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
@classmethod @classmethod
def _get_unified_job_field_names(cls): def _get_unified_job_field_names(cls):
return set(f.name for f in InventorySourceOptions._meta.fields) | set( return set(f.name for f in InventorySourceOptions._meta.fields) | set(
['name', 'description', 'schedule', 'credentials', 'inventory'] ['name', 'description', 'credentials', 'inventory']
) )
def save(self, *args, **kwargs): def save(self, *args, **kwargs):

View File

@@ -39,7 +39,7 @@ from awx.main.models.notifications import (
NotificationTemplate, NotificationTemplate,
JobNotificationMixin, JobNotificationMixin,
) )
from awx.main.utils import parse_yaml_or_json, getattr_dne from awx.main.utils import parse_yaml_or_json, getattr_dne, NullablePromptPseudoField
from awx.main.fields import ImplicitRoleField, JSONField, AskForField from awx.main.fields import ImplicitRoleField, JSONField, AskForField
from awx.main.models.mixins import ( from awx.main.models.mixins import (
ResourceMixin, ResourceMixin,
@@ -271,7 +271,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
@classmethod @classmethod
def _get_unified_job_field_names(cls): def _get_unified_job_field_names(cls):
return set(f.name for f in JobOptions._meta.fields) | set( return set(f.name for f in JobOptions._meta.fields) | set(
['name', 'description', 'schedule', 'survey_passwords', 'labels', 'credentials', ['name', 'description', 'survey_passwords', 'labels', 'credentials',
'job_slice_number', 'job_slice_count'] 'job_slice_number', 'job_slice_count']
) )
@@ -839,25 +839,6 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
host.save() host.save()
# Add on aliases for the non-related-model fields
class NullablePromptPsuedoField(object):
"""
Interface for psuedo-property stored in `char_prompts` dict
Used in LaunchTimeConfig and submodels
"""
def __init__(self, field_name):
self.field_name = field_name
def __get__(self, instance, type=None):
return instance.char_prompts.get(self.field_name, None)
def __set__(self, instance, value):
if value in (None, {}):
instance.char_prompts.pop(self.field_name, None)
else:
instance.char_prompts[self.field_name] = value
class LaunchTimeConfigBase(BaseModel): class LaunchTimeConfigBase(BaseModel):
''' '''
Needed as separate class from LaunchTimeConfig because some models Needed as separate class from LaunchTimeConfig because some models
@@ -878,6 +859,7 @@ class LaunchTimeConfigBase(BaseModel):
null=True, null=True,
default=None, default=None,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
help_text=_('Inventory applied as a prompt, assuming job template prompts for inventory')
) )
# 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
@@ -914,21 +896,14 @@ class LaunchTimeConfigBase(BaseModel):
data[prompt_name] = prompt_val data[prompt_name] = prompt_val
return data return data
def display_extra_vars(self):
'''
Hides fields marked as passwords in survey.
'''
if self.survey_passwords:
extra_vars = parse_yaml_or_json(self.extra_vars).copy()
for key, value in self.survey_passwords.items():
if key in extra_vars:
extra_vars[key] = value
return extra_vars
else:
return self.extra_vars
def display_extra_data(self): for field_name in JobTemplate.get_ask_mapping().keys():
return self.display_extra_vars() if field_name == 'extra_vars':
continue
try:
LaunchTimeConfigBase._meta.get_field(field_name)
except FieldDoesNotExist:
setattr(LaunchTimeConfigBase, field_name, NullablePromptPseudoField(field_name))
class LaunchTimeConfig(LaunchTimeConfigBase): class LaunchTimeConfig(LaunchTimeConfigBase):
@@ -963,14 +938,21 @@ class LaunchTimeConfig(LaunchTimeConfigBase):
def extra_vars(self, extra_vars): def extra_vars(self, extra_vars):
self.extra_data = extra_vars self.extra_data = extra_vars
def display_extra_vars(self):
'''
Hides fields marked as passwords in survey.
'''
if hasattr(self, 'survey_passwords') and self.survey_passwords:
extra_vars = parse_yaml_or_json(self.extra_vars).copy()
for key, value in self.survey_passwords.items():
if key in extra_vars:
extra_vars[key] = value
return extra_vars
else:
return self.extra_vars
for field_name in JobTemplate.get_ask_mapping().keys(): def display_extra_data(self):
if field_name == 'extra_vars': return self.display_extra_vars()
continue
try:
LaunchTimeConfig._meta.get_field(field_name)
except FieldDoesNotExist:
setattr(LaunchTimeConfig, field_name, NullablePromptPsuedoField(field_name))
class JobLaunchConfig(LaunchTimeConfig): class JobLaunchConfig(LaunchTimeConfig):

View File

@@ -329,7 +329,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
@classmethod @classmethod
def _get_unified_job_field_names(cls): def _get_unified_job_field_names(cls):
return set(f.name for f in ProjectOptions._meta.fields) | set( return set(f.name for f in ProjectOptions._meta.fields) | set(
['name', 'description', 'schedule'] ['name', 'description']
) )
def save(self, *args, **kwargs): def save(self, *args, **kwargs):

View File

@@ -19,7 +19,7 @@ from awx.main.models.notifications import (
NotificationTemplate, NotificationTemplate,
JobNotificationMixin JobNotificationMixin
) )
from awx.main.models.base import BaseModel, CreatedModifiedModel, VarsDictProperty from awx.main.models.base import CreatedModifiedModel, VarsDictProperty
from awx.main.models.rbac import ( from awx.main.models.rbac import (
ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
ROLE_SINGLETON_SYSTEM_AUDITOR ROLE_SINGLETON_SYSTEM_AUDITOR
@@ -207,11 +207,14 @@ class WorkflowJobNode(WorkflowNodeBase):
def prompts_dict(self, *args, **kwargs): def prompts_dict(self, *args, **kwargs):
r = super(WorkflowJobNode, self).prompts_dict(*args, **kwargs) r = super(WorkflowJobNode, self).prompts_dict(*args, **kwargs)
# Explanation - WFJT extra_vars still break pattern, so they are not # Explanation - WFJT extra_vars still break pattern, so they are not
# put through prompts processing, but inventory is only accepted # put through prompts processing, but inventory and others are only accepted
# if JT prompts for it, so it goes through this mechanism # if JT prompts for it, so it goes through this mechanism
if self.workflow_job and self.workflow_job.inventory_id: if self.workflow_job:
# workflow job inventory takes precedence if self.workflow_job.inventory_id:
r['inventory'] = self.workflow_job.inventory # workflow job inventory takes precedence
r['inventory'] = self.workflow_job.inventory
if self.workflow_job.char_prompts:
r.update(self.workflow_job.char_prompts)
return r return r
def get_job_kwargs(self): def get_job_kwargs(self):
@@ -298,7 +301,7 @@ class WorkflowJobNode(WorkflowNodeBase):
return data return data
class WorkflowJobOptions(BaseModel): class WorkflowJobOptions(LaunchTimeConfigBase):
class Meta: class Meta:
abstract = True abstract = True
@@ -318,10 +321,11 @@ 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( r = set(f.name for f in WorkflowJobOptions._meta.fields) | set(
# NOTE: if other prompts are added to WFJT, put fields in WJOptions, remove inventory ['name', 'description', 'survey_passwords', 'labels', 'limit', 'scm_branch']
['name', 'description', 'schedule', 'survey_passwords', 'labels', 'inventory']
) )
r.remove('char_prompts') # needed due to copying launch config to launch config
return r
def _create_workflow_nodes(self, old_node_list, user=None): def _create_workflow_nodes(self, old_node_list, user=None):
node_links = {} node_links = {}
@@ -372,19 +376,18 @@ 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( ask_inventory_on_launch = AskForField(
blank=True, blank=True,
default=False, default=False,
) )
ask_limit_on_launch = AskForField(
blank=True,
default=False,
)
ask_scm_branch_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'
@@ -515,7 +518,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, LaunchTimeConfigBase): class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin):
class Meta: class Meta:
app_label = 'main' app_label = 'main'
ordering = ('id',) ordering = ('id',)

View File

@@ -2235,7 +2235,7 @@ class RunInventoryUpdate(BaseTask):
getattr(settings, '%s_INSTANCE_ID_VAR' % src.upper()),]) getattr(settings, '%s_INSTANCE_ID_VAR' % src.upper()),])
# Add arguments for the source inventory script # Add arguments for the source inventory script
args.append('--source') args.append('--source')
args.append(self.psuedo_build_inventory(inventory_update, private_data_dir)) args.append(self.pseudo_build_inventory(inventory_update, private_data_dir))
if src == 'custom': if src == 'custom':
args.append("--custom") args.append("--custom")
args.append('-v%d' % inventory_update.verbosity) args.append('-v%d' % inventory_update.verbosity)
@@ -2246,7 +2246,7 @@ class RunInventoryUpdate(BaseTask):
def build_inventory(self, inventory_update, private_data_dir): def build_inventory(self, inventory_update, private_data_dir):
return None # what runner expects in order to not deal with inventory return None # what runner expects in order to not deal with inventory
def psuedo_build_inventory(self, inventory_update, private_data_dir): def pseudo_build_inventory(self, inventory_update, private_data_dir):
"""Inventory imports are ran through a management command """Inventory imports are ran through a management command
we pass the inventory in args to that command, so this is not considered we pass the inventory in args to that command, so this is not considered
to be "Ansible" inventory (by runner) even though it is to be "Ansible" inventory (by runner) even though it is

View File

@@ -1,6 +1,8 @@
# Python # Python
import pytest import pytest
from unittest import mock
import json
# AWX # AWX
from awx.main.models.workflow import ( from awx.main.models.workflow import (
@@ -248,7 +250,6 @@ class TestWorkflowJobTemplate:
test_view = WorkflowJobTemplateNodeSuccessNodesList() test_view = WorkflowJobTemplateNodeSuccessNodesList()
nodes = wfjt.workflow_job_template_nodes.all() nodes = wfjt.workflow_job_template_nodes.all()
# test cycle validation # test cycle validation
print(nodes[0].success_nodes.get(id=nodes[1].id).failure_nodes.get(id=nodes[2].id))
assert test_view.is_valid_relation(nodes[2], nodes[0]) == {'Error': 'Cycle detected.'} assert test_view.is_valid_relation(nodes[2], nodes[0]) == {'Error': 'Cycle detected.'}
def test_always_success_failure_creation(self, wfjt, admin, get): def test_always_success_failure_creation(self, wfjt, admin, get):
@@ -270,6 +271,103 @@ class TestWorkflowJobTemplate:
wfjt2.validate_unique() wfjt2.validate_unique()
@pytest.mark.django_db
class TestWorkflowJobTemplatePrompts:
"""These are tests for prompts that live on the workflow job template model
not the node, prompts apply for entire workflow
"""
@pytest.fixture
def wfjt_prompts(self):
return WorkflowJobTemplate.objects.create(
ask_inventory_on_launch=True,
ask_variables_on_launch=True,
ask_limit_on_launch=True,
ask_scm_branch_on_launch=True
)
@pytest.fixture
def prompts_data(self, inventory):
return dict(
inventory=inventory,
extra_vars={'foo': 'bar'},
limit='webservers',
scm_branch='release-3.3'
)
def test_apply_workflow_job_prompts(self, workflow_job_template, wfjt_prompts, prompts_data, inventory):
# null or empty fields used
workflow_job = workflow_job_template.create_unified_job()
assert workflow_job.limit is None
assert workflow_job.inventory is None
assert workflow_job.scm_branch is None
# fields from prompts used
workflow_job = workflow_job_template.create_unified_job(**prompts_data)
assert json.loads(workflow_job.extra_vars) == {'foo': 'bar'}
assert workflow_job.limit == 'webservers'
assert workflow_job.inventory == inventory
assert workflow_job.scm_branch == 'release-3.3'
# non-null fields from WFJT used
workflow_job_template.inventory = inventory
workflow_job_template.limit = 'fooo'
workflow_job_template.scm_branch = 'bar'
workflow_job = workflow_job_template.create_unified_job()
assert workflow_job.limit == 'fooo'
assert workflow_job.inventory == inventory
assert workflow_job.scm_branch == 'bar'
@pytest.mark.django_db
def test_process_workflow_job_prompts(self, inventory, workflow_job_template, wfjt_prompts, prompts_data):
accepted, rejected, errors = workflow_job_template._accept_or_ignore_job_kwargs(**prompts_data)
assert accepted == {}
assert rejected == prompts_data
assert errors
accepted, rejected, errors = wfjt_prompts._accept_or_ignore_job_kwargs(**prompts_data)
assert accepted == prompts_data
assert rejected == {}
assert not errors
@pytest.mark.django_db
def test_set_all_the_prompts(self, post, organization, inventory, org_admin):
r = post(
url = reverse('api:workflow_job_template_list'),
data = dict(
name='My new workflow',
organization=organization.id,
inventory=inventory.id,
limit='foooo',
ask_limit_on_launch=True,
scm_branch='bar',
ask_scm_branch_on_launch=True
),
user = org_admin,
expect = 201
)
wfjt = WorkflowJobTemplate.objects.get(id=r.data['id'])
assert wfjt.char_prompts == {
'limit': 'foooo', 'scm_branch': 'bar'
}
assert wfjt.ask_scm_branch_on_launch is True
assert wfjt.ask_limit_on_launch is True
launch_url = r.data['related']['launch']
with mock.patch('awx.main.queue.CallbackQueueDispatcher.dispatch', lambda self, obj: None):
r = post(
url = launch_url,
data = dict(
scm_branch = 'prompt_branch',
limit = 'prompt_limit'
),
user = org_admin,
expect=201
)
assert r.data['limit'] == 'prompt_limit'
assert r.data['scm_branch'] == 'prompt_branch'
@pytest.mark.django_db @pytest.mark.django_db
def test_workflow_ancestors(organization): def test_workflow_ancestors(organization):
# Spawn order of templates grandparent -> parent -> child # Spawn order of templates grandparent -> parent -> child

View File

@@ -3,7 +3,7 @@ import pytest
from awx.main.models.jobs import JobTemplate from awx.main.models.jobs import JobTemplate
from awx.main.models import Inventory, CredentialType, Credential, Project from awx.main.models import Inventory, CredentialType, Credential, Project
from awx.main.models.workflow import ( from awx.main.models.workflow import (
WorkflowJobTemplate, WorkflowJobTemplateNode, WorkflowJobOptions, WorkflowJobTemplate, WorkflowJobTemplateNode,
WorkflowJob, WorkflowJobNode WorkflowJob, WorkflowJobNode
) )
from unittest import mock from unittest import mock
@@ -33,11 +33,11 @@ class TestWorkflowJobInheritNodesMixin():
def test__create_workflow_job_nodes(self, mocker, job_template_nodes): def test__create_workflow_job_nodes(self, mocker, job_template_nodes):
workflow_job_node_create = mocker.patch('awx.main.models.WorkflowJobTemplateNode.create_workflow_job_node') workflow_job_node_create = mocker.patch('awx.main.models.WorkflowJobTemplateNode.create_workflow_job_node')
mixin = WorkflowJobOptions() workflow_job = WorkflowJob()
mixin._create_workflow_nodes(job_template_nodes) workflow_job._create_workflow_nodes(job_template_nodes)
for job_template_node in job_template_nodes: for job_template_node in job_template_nodes:
workflow_job_node_create.assert_any_call(workflow_job=mixin) workflow_job_node_create.assert_any_call(workflow_job=workflow_job)
class TestMapWorkflowJobNodes(): class TestMapWorkflowJobNodes():
@pytest.fixture @pytest.fixture
@@ -236,4 +236,4 @@ class TestWorkflowJobNodeJobKWARGS:
def test_get_ask_mapping_integrity(): def test_get_ask_mapping_integrity():
assert list(WorkflowJobTemplate.get_ask_mapping().keys()) == ['extra_vars', 'inventory'] assert list(WorkflowJobTemplate.get_ask_mapping().keys()) == ['extra_vars', 'inventory', 'limit', 'scm_branch']

View File

@@ -19,9 +19,14 @@ from functools import reduce, wraps
from decimal import Decimal from decimal import Decimal
# Django # Django
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils.functional import cached_property
from django.db.models.fields.related import ForeignObjectRel, ManyToManyField from django.db.models.fields.related import ForeignObjectRel, ManyToManyField
from django.db.models.fields.related_descriptors import (
ForwardManyToOneDescriptor,
ManyToManyDescriptor
)
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.db.models import Q from django.db.models import Q
@@ -42,7 +47,7 @@ __all__ = ['get_object_or_400', 'camelcase_to_underscore', 'underscore_to_camelc
'get_current_apps', 'set_current_apps', 'get_current_apps', 'set_current_apps',
'extract_ansible_vars', 'get_search_fields', 'get_system_task_capacity', 'get_cpu_capacity', 'get_mem_capacity', 'extract_ansible_vars', 'get_search_fields', 'get_system_task_capacity', 'get_cpu_capacity', 'get_mem_capacity',
'wrap_args_with_proot', 'build_proot_temp_dir', 'check_proot_installed', 'model_to_dict', 'wrap_args_with_proot', 'build_proot_temp_dir', 'check_proot_installed', 'model_to_dict',
'model_instance_diff', 'parse_yaml_or_json', 'RequireDebugTrueOrTest', 'NullablePromptPseudoField', 'model_instance_diff', 'parse_yaml_or_json', 'RequireDebugTrueOrTest',
'has_model_field_prefetched', 'set_environ', 'IllegalArgumentError', 'get_custom_venv_choices', 'get_external_account', 'has_model_field_prefetched', 'set_environ', 'IllegalArgumentError', 'get_custom_venv_choices', 'get_external_account',
'task_manager_bulk_reschedule', 'schedule_task_manager', 'classproperty', 'create_temporary_fifo'] 'task_manager_bulk_reschedule', 'schedule_task_manager', 'classproperty', 'create_temporary_fifo']
@@ -435,6 +440,39 @@ def model_to_dict(obj, serializer_mapping=None):
return attr_d return attr_d
class CharPromptDescriptor:
"""Class used for identifying nullable launch config fields from class
ex. Schedule.limit
"""
def __init__(self, field):
self.field = field
class NullablePromptPseudoField:
"""
Interface for pseudo-property stored in `char_prompts` dict
Used in LaunchTimeConfig and submodels, defined here to avoid circular imports
"""
def __init__(self, field_name):
self.field_name = field_name
@cached_property
def field_descriptor(self):
return CharPromptDescriptor(self)
def __get__(self, instance, type=None):
if instance is None:
# for inspection on class itself
return self.field_descriptor
return instance.char_prompts.get(self.field_name, None)
def __set__(self, instance, value):
if value in (None, {}):
instance.char_prompts.pop(self.field_name, None)
else:
instance.char_prompts[self.field_name] = value
def copy_model_by_class(obj1, Class2, fields, kwargs): def copy_model_by_class(obj1, Class2, fields, kwargs):
''' '''
Creates a new unsaved object of type Class2 using the fields from obj1 Creates a new unsaved object of type Class2 using the fields from obj1
@@ -442,9 +480,10 @@ def copy_model_by_class(obj1, Class2, fields, kwargs):
''' '''
create_kwargs = {} create_kwargs = {}
for field_name in fields: for field_name in fields:
# Foreign keys can be specified as field_name or field_name_id. descriptor = getattr(Class2, field_name)
id_field_name = '%s_id' % field_name if isinstance(descriptor, ForwardManyToOneDescriptor): # ForeignKey
if hasattr(obj1, id_field_name): # Foreign keys can be specified as field_name or field_name_id.
id_field_name = '%s_id' % field_name
if field_name in kwargs: if field_name in kwargs:
value = kwargs[field_name] value = kwargs[field_name]
elif id_field_name in kwargs: elif id_field_name in kwargs:
@@ -454,15 +493,29 @@ def copy_model_by_class(obj1, Class2, fields, kwargs):
if hasattr(value, 'id'): if hasattr(value, 'id'):
value = value.id value = value.id
create_kwargs[id_field_name] = value create_kwargs[id_field_name] = value
elif isinstance(descriptor, CharPromptDescriptor):
# difficult case of copying one launch config to another launch config
new_val = None
if field_name in kwargs:
new_val = kwargs[field_name]
elif hasattr(obj1, 'char_prompts'):
if field_name in obj1.char_prompts:
new_val = obj1.char_prompts[field_name]
elif hasattr(obj1, field_name):
# extremely rare case where a template spawns a launch config - sliced jobs
new_val = getattr(obj1, field_name)
if new_val is not None:
create_kwargs.setdefault('char_prompts', {})
create_kwargs['char_prompts'][field_name] = new_val
elif isinstance(descriptor, ManyToManyDescriptor):
continue # not copied in this method
elif field_name in kwargs: elif field_name in kwargs:
if field_name == 'extra_vars' and isinstance(kwargs[field_name], dict): if field_name == 'extra_vars' and isinstance(kwargs[field_name], dict):
create_kwargs[field_name] = json.dumps(kwargs['extra_vars']) create_kwargs[field_name] = json.dumps(kwargs['extra_vars'])
elif not isinstance(Class2._meta.get_field(field_name), (ForeignObjectRel, ManyToManyField)): elif not isinstance(Class2._meta.get_field(field_name), (ForeignObjectRel, ManyToManyField)):
create_kwargs[field_name] = kwargs[field_name] create_kwargs[field_name] = kwargs[field_name]
elif hasattr(obj1, field_name): elif hasattr(obj1, field_name):
field_obj = obj1._meta.get_field(field_name) create_kwargs[field_name] = getattr(obj1, field_name)
if not isinstance(field_obj, ManyToManyField):
create_kwargs[field_name] = getattr(obj1, field_name)
# Apply class-specific extra processing for origination of unified jobs # Apply class-specific extra processing for origination of unified jobs
if hasattr(obj1, '_update_unified_job_kwargs') and obj1.__class__ != Class2: if hasattr(obj1, '_update_unified_job_kwargs') and obj1.__class__ != Class2:
@@ -481,7 +534,10 @@ def copy_m2m_relationships(obj1, obj2, fields, kwargs=None):
''' '''
for field_name in fields: for field_name in fields:
if hasattr(obj1, field_name): if hasattr(obj1, field_name):
field_obj = obj1._meta.get_field(field_name) try:
field_obj = obj1._meta.get_field(field_name)
except FieldDoesNotExist:
continue
if isinstance(field_obj, ManyToManyField): if isinstance(field_obj, ManyToManyField):
# Many to Many can be specified as field_name # Many to Many can be specified as field_name
src_field_value = getattr(obj1, field_name) src_field_value = getattr(obj1, field_name)

View File

@@ -58,8 +58,9 @@ function canLaunchWithoutPrompt () {
launchData.can_start_without_user_input && launchData.can_start_without_user_input &&
!launchData.ask_inventory_on_launch && !launchData.ask_inventory_on_launch &&
!launchData.ask_variables_on_launch && !launchData.ask_variables_on_launch &&
!launchData.survey_enabled && !launchData.ask_limit_on_launch &&
!launchData.ask_scm_branch_on_launch && !launchData.ask_scm_branch_on_launch &&
!launchData.survey_enabled &&
launchData.variables_needed_to_start.length === 0 launchData.variables_needed_to_start.length === 0
); );
} }

View File

@@ -89,6 +89,34 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n) {
}, },
ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddOrEdit) || !canEditInventory', ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddOrEdit) || !canEditInventory',
}, },
limit: {
label: i18n._('Limit'),
type: 'text',
column: 1,
awPopOver: "<p>" + i18n._("Provide a host pattern to further constrain the list of hosts that will be managed or affected by the workflow. This limit is applied to all job template nodes that prompt for a limit. Refer to Ansible documentation for more information and examples on patterns.") + "</p>",
dataTitle: i18n._('Limit'),
dataPlacement: 'right',
dataContainer: "body",
subCheckbox: {
variable: 'ask_limit_on_launch',
text: i18n._('Prompt on launch')
},
ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddOrEdit) || !canEditInventory',
},
scm_branch: {
label: i18n._('SCM Branch'),
type: 'text',
column: 1,
awPopOver: "<p>" + i18n._("Select a branch for the workflow. This branch is applied to all job template nodes that prompt for a branch.") + "</p>",
dataTitle: i18n._('SCM Branch'),
dataPlacement: 'right',
dataContainer: "body",
subCheckbox: {
variable: 'ask_scm_branch_on_launch',
text: i18n._('Prompt on launch')
},
ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddOrEdit)',
},
labels: { labels: {
label: i18n._('Labels'), label: i18n._('Labels'),
type: 'select', type: 'select',

View File

@@ -70,9 +70,11 @@ export default [
data[fld] = $scope[fld]; data[fld] = $scope[fld];
} }
} }
data.ask_inventory_on_launch = Boolean($scope.ask_inventory_on_launch); data.ask_inventory_on_launch = Boolean($scope.ask_inventory_on_launch);
data.ask_variables_on_launch = Boolean($scope.ask_variables_on_launch); data.ask_variables_on_launch = Boolean($scope.ask_variables_on_launch);
data.ask_limit_on_launch = Boolean($scope.ask_limit_on_launch);
data.ask_scm_branch_on_launch = Boolean($scope.ask_scm_branch_on_launch);
data.extra_vars = ToJSON($scope.parseType, data.extra_vars = ToJSON($scope.parseType,
$scope.variables, true); $scope.variables, true);

View File

@@ -54,6 +54,8 @@ export default [
$scope.parseType = 'yaml'; $scope.parseType = 'yaml';
$scope.includeWorkflowMaker = false; $scope.includeWorkflowMaker = false;
$scope.ask_inventory_on_launch = workflowJobTemplateData.ask_inventory_on_launch; $scope.ask_inventory_on_launch = workflowJobTemplateData.ask_inventory_on_launch;
$scope.ask_limit_on_launch = workflowJobTemplateData.ask_limit_on_launch;
$scope.ask_scm_branch_on_launch = workflowJobTemplateData.ask_scm_branch_on_launch;
$scope.ask_variables_on_launch = (workflowJobTemplateData.ask_variables_on_launch) ? true : false; $scope.ask_variables_on_launch = (workflowJobTemplateData.ask_variables_on_launch) ? true : false;
if (Inventory){ if (Inventory){
@@ -91,6 +93,8 @@ export default [
} }
data.ask_inventory_on_launch = Boolean($scope.ask_inventory_on_launch); data.ask_inventory_on_launch = Boolean($scope.ask_inventory_on_launch);
data.ask_limit_on_launch = Boolean($scope.ask_limit_on_launch);
data.ask_scm_branch_on_launch = Boolean($scope.ask_scm_branch_on_launch);
data.ask_variables_on_launch = Boolean($scope.ask_variables_on_launch); data.ask_variables_on_launch = Boolean($scope.ask_variables_on_launch);
data.extra_vars = ToJSON($scope.parseType, data.extra_vars = ToJSON($scope.parseType,

View File

@@ -69,7 +69,9 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions',
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') INVENTORY: i18n._('Inventory'),
LIMIT: i18n._('Inventory Limit'),
SCM_BRANCH: i18n._('SCM Branch')
}, },
details: { details: {
HEADER: i18n._('DETAILS'), HEADER: i18n._('DETAILS'),

View File

@@ -140,6 +140,26 @@
</div> </div>
</div> </div>
<!-- LIMIT -->
<div class="WorkflowResults-resultRow" ng-show="workflow.limit">
<label class="WorkflowResults-resultRowLabel">
{{ strings.labels.LIMIT }}
</label>
<div class="WorkflowResults-resultRowText">
{{ workflow.limit }}
</div>
</div>
<!-- BRANCH -->
<div class="WorkflowResults-resultRow" ng-show="workflow.scm_branch">
<label class="WorkflowResults-resultRowLabel">
{{ strings.labels.SCM_BRANCH }}
</label>
<div class="WorkflowResults-resultRowText">
{{ workflow.scm_branch }}
</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

@@ -144,11 +144,15 @@ describe('Controller: WorkflowAdd', () => {
description: "This is a test description", description: "This is a test description",
organization: undefined, organization: undefined,
inventory: undefined, inventory: undefined,
limit: undefined,
scm_branch: undefined,
labels: undefined, labels: undefined,
variables: undefined, variables: undefined,
allow_simultaneous: undefined, allow_simultaneous: undefined,
ask_inventory_on_launch: false, ask_inventory_on_launch: false,
ask_variables_on_launch: false, ask_variables_on_launch: false,
ask_limit_on_launch: false,
ask_scm_branch_on_launch: false,
extra_vars: undefined extra_vars: undefined
}); });
}); });

View File

@@ -25,6 +25,7 @@ class WorkflowJobTemplateNode(HasCreate, base.Base):
'diff_mode', 'diff_mode',
'extra_data', 'extra_data',
'limit', 'limit',
'scm_branch',
'job_tags', 'job_tags',
'job_type', 'job_type',
'skip_tags', 'skip_tags',

View File

@@ -34,7 +34,12 @@ class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, Unifi
payload = PseudoNamespace(name=kwargs.get('name') or 'WorkflowJobTemplate - {}'.format(random_title()), payload = PseudoNamespace(name=kwargs.get('name') or 'WorkflowJobTemplate - {}'.format(random_title()),
description=kwargs.get('description') or random_title(10)) description=kwargs.get('description') or random_title(10))
optional_fields = ("allow_simultaneous", "ask_variables_on_launch", "survey_enabled") optional_fields = (
"allow_simultaneous",
"ask_variables_on_launch", "ask_inventory_on_launch", "ask_scm_branch_on_launch", "ask_limit_on_launch",
"limit", "scm_branch",
"survey_enabled"
)
update_payload(payload, optional_fields, kwargs) update_payload(payload, optional_fields, kwargs)
extra_vars = kwargs.get('extra_vars', not_provided) extra_vars = kwargs.get('extra_vars', not_provided)
@@ -48,8 +53,6 @@ class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, Unifi
if kwargs.get('inventory'): if kwargs.get('inventory'):
payload.inventory = kwargs.get('inventory').id payload.inventory = kwargs.get('inventory').id
if kwargs.get('ask_inventory_on_launch'):
payload.ask_inventory_on_launch = kwargs.get('ask_inventory_on_launch')
return payload return payload

View File

@@ -59,7 +59,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 accept extra_vars and inventory - can accept certain fields, see `workflow.md`
- 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
@@ -174,7 +174,7 @@ job. If a user creates a node that would do this, a 400 response will be returne
Workflow JTs are different than other cases, because they do not have a 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. 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 When the node's prompts are gathered to spawn its job, any prompts from the workflow job
will take precedence over the node's value. will take precedence over the node's value.
As a special exception, `extra_vars` from a workflow will not obey JT survey As a special exception, `extra_vars` from a workflow will not obey JT survey
@@ -182,8 +182,7 @@ and prompting rules, both both historical and ease-of-understanding reasons.
This behavior may change in the future. This behavior may change in the future.
Other than that exception, JT prompting rules are still adhered to when 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 a job is spawned.
`inventory` field.
#### Job Relaunch and Re-scheduling #### Job Relaunch and Re-scheduling

View File

@@ -8,7 +8,7 @@ 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. 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.
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. 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.
@@ -20,7 +20,12 @@ Workflow job template nodes are listed and created under endpoint `/workflow_job
#### Workflow Launch Configuration #### Workflow Launch Configuration
Workflow job templates can contain launch configuration items. So far, these only include 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 - `extra_vars`
- `inventory`
- `limit`
- `scm_branch`
The `extra_vars` field may have specifications via
a survey, in the same way that job templates work. 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.