mirror of
https://github.com/ansible/awx.git
synced 2026-05-03 23:55:28 -02:30
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:
@@ -3314,11 +3314,14 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo
|
||||
'admin', 'execute',
|
||||
{'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:
|
||||
model = WorkflowJobTemplate
|
||||
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):
|
||||
res = super(WorkflowJobTemplateSerializer, self).get_related(obj)
|
||||
@@ -3344,6 +3347,22 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo
|
||||
def validate_extra_vars(self, 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):
|
||||
'''
|
||||
@@ -3356,13 +3375,15 @@ class WorkflowJobTemplateWithSpecSerializer(WorkflowJobTemplateSerializer):
|
||||
|
||||
|
||||
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:
|
||||
model = WorkflowJob
|
||||
fields = ('*', 'workflow_job_template', 'extra_vars', 'allow_simultaneous',
|
||||
'job_template', 'is_sliced_job',
|
||||
'-execution_node', '-event_processing_finished', '-controller_node',
|
||||
'inventory',)
|
||||
'inventory', 'limit', 'scm_branch',)
|
||||
|
||||
def get_related(self, obj):
|
||||
res = super(WorkflowJobSerializer, self).get_related(obj)
|
||||
@@ -3596,7 +3617,7 @@ class LaunchConfigurationBaseSerializer(BaseSerializer):
|
||||
if 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:
|
||||
attrs['char_prompts'] = mock_obj.char_prompts
|
||||
|
||||
@@ -4180,12 +4201,16 @@ class WorkflowJobLaunchSerializer(BaseSerializer):
|
||||
queryset=Inventory.objects.all(),
|
||||
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()
|
||||
|
||||
class Meta:
|
||||
model = WorkflowJobTemplate
|
||||
fields = ('ask_inventory_on_launch', 'can_start_without_user_input', 'defaults', 'extra_vars',
|
||||
'inventory', 'survey_enabled', 'variables_needed_to_start',
|
||||
fields = ('ask_inventory_on_launch', 'ask_limit_on_launch', 'ask_scm_branch_on_launch',
|
||||
'can_start_without_user_input', 'defaults', 'extra_vars',
|
||||
'inventory', 'limit', 'scm_branch',
|
||||
'survey_enabled', 'variables_needed_to_start',
|
||||
'node_templates_missing', 'node_prompts_rejected',
|
||||
'workflow_job_template_data', 'survey_enabled', '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_inventory = template.inventory
|
||||
WFJT_limit = template.limit
|
||||
WFJT_scm_branch = template.scm_branch
|
||||
super(WorkflowJobLaunchSerializer, self).validate(attrs)
|
||||
template.extra_vars = WFJT_extra_vars
|
||||
template.inventory = WFJT_inventory
|
||||
template.limit = WFJT_limit
|
||||
template.scm_branch = WFJT_scm_branch
|
||||
|
||||
return accepted
|
||||
|
||||
|
||||
|
||||
@@ -3111,6 +3111,17 @@ class WorkflowJobTemplateCopy(CopyAPIView):
|
||||
data.update(messages)
|
||||
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
|
||||
def deep_copy_permission_check_func(user, new_objs):
|
||||
for obj in new_objs:
|
||||
@@ -3139,7 +3150,6 @@ class WorkflowJobTemplateLabelList(JobTemplateLabelList):
|
||||
|
||||
class WorkflowJobTemplateLaunch(RetrieveAPIView):
|
||||
|
||||
|
||||
model = models.WorkflowJobTemplate
|
||||
obj_permission_type = 'start'
|
||||
serializer_class = serializers.WorkflowJobLaunchSerializer
|
||||
@@ -3156,10 +3166,15 @@ class WorkflowJobTemplateLaunch(RetrieveAPIView):
|
||||
extra_vars.setdefault(v, u'')
|
||||
if extra_vars:
|
||||
data['extra_vars'] = extra_vars
|
||||
if obj.ask_inventory_on_launch:
|
||||
data['inventory'] = obj.inventory_id
|
||||
else:
|
||||
data.pop('inventory', None)
|
||||
modified_ask_mapping = models.WorkflowJobTemplate.get_ask_mapping()
|
||||
modified_ask_mapping.pop('extra_vars')
|
||||
for field_name, ask_field_name in obj.get_ask_mapping().items():
|
||||
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
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
59
awx/main/migrations/0090_v360_WFJT_prompts.py
Normal file
59
awx/main/migrations/0090_v360_WFJT_prompts.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -1501,7 +1501,7 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
|
||||
@classmethod
|
||||
def _get_unified_job_field_names(cls):
|
||||
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):
|
||||
|
||||
@@ -39,7 +39,7 @@ from awx.main.models.notifications import (
|
||||
NotificationTemplate,
|
||||
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.models.mixins import (
|
||||
ResourceMixin,
|
||||
@@ -271,7 +271,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
|
||||
@classmethod
|
||||
def _get_unified_job_field_names(cls):
|
||||
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']
|
||||
)
|
||||
|
||||
@@ -839,25 +839,6 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
||||
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):
|
||||
'''
|
||||
Needed as separate class from LaunchTimeConfig because some models
|
||||
@@ -878,6 +859,7 @@ class LaunchTimeConfigBase(BaseModel):
|
||||
null=True,
|
||||
default=None,
|
||||
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
|
||||
# This is a solution to the nullable CharField problem, specific to prompting
|
||||
@@ -914,21 +896,14 @@ class LaunchTimeConfigBase(BaseModel):
|
||||
data[prompt_name] = prompt_val
|
||||
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):
|
||||
return self.display_extra_vars()
|
||||
for field_name in JobTemplate.get_ask_mapping().keys():
|
||||
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):
|
||||
@@ -963,14 +938,21 @@ class LaunchTimeConfig(LaunchTimeConfigBase):
|
||||
def extra_vars(self, 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():
|
||||
if field_name == 'extra_vars':
|
||||
continue
|
||||
try:
|
||||
LaunchTimeConfig._meta.get_field(field_name)
|
||||
except FieldDoesNotExist:
|
||||
setattr(LaunchTimeConfig, field_name, NullablePromptPsuedoField(field_name))
|
||||
def display_extra_data(self):
|
||||
return self.display_extra_vars()
|
||||
|
||||
|
||||
class JobLaunchConfig(LaunchTimeConfig):
|
||||
|
||||
@@ -329,7 +329,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
|
||||
@classmethod
|
||||
def _get_unified_job_field_names(cls):
|
||||
return set(f.name for f in ProjectOptions._meta.fields) | set(
|
||||
['name', 'description', 'schedule']
|
||||
['name', 'description']
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -19,7 +19,7 @@ from awx.main.models.notifications import (
|
||||
NotificationTemplate,
|
||||
JobNotificationMixin
|
||||
)
|
||||
from awx.main.models.base import BaseModel, CreatedModifiedModel, VarsDictProperty
|
||||
from awx.main.models.base import CreatedModifiedModel, VarsDictProperty
|
||||
from awx.main.models.rbac import (
|
||||
ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
|
||||
ROLE_SINGLETON_SYSTEM_AUDITOR
|
||||
@@ -207,11 +207,14 @@ class WorkflowJobNode(WorkflowNodeBase):
|
||||
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
|
||||
# put through prompts processing, but inventory and others are 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
|
||||
if self.workflow_job:
|
||||
if self.workflow_job.inventory_id:
|
||||
# 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
|
||||
|
||||
def get_job_kwargs(self):
|
||||
@@ -298,7 +301,7 @@ class WorkflowJobNode(WorkflowNodeBase):
|
||||
return data
|
||||
|
||||
|
||||
class WorkflowJobOptions(BaseModel):
|
||||
class WorkflowJobOptions(LaunchTimeConfigBase):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -318,10 +321,11 @@ class WorkflowJobOptions(BaseModel):
|
||||
|
||||
@classmethod
|
||||
def _get_unified_job_field_names(cls):
|
||||
return 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', 'schedule', 'survey_passwords', 'labels', 'inventory']
|
||||
r = set(f.name for f in WorkflowJobOptions._meta.fields) | set(
|
||||
['name', 'description', 'survey_passwords', 'labels', 'limit', 'scm_branch']
|
||||
)
|
||||
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):
|
||||
node_links = {}
|
||||
@@ -372,19 +376,18 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='workflows',
|
||||
)
|
||||
inventory = models.ForeignKey(
|
||||
'Inventory',
|
||||
related_name='%(class)ss',
|
||||
blank=True,
|
||||
null=True,
|
||||
default=None,
|
||||
on_delete=models.SET_NULL,
|
||||
help_text=_('Inventory applied to all job templates in workflow that prompt for inventory.'),
|
||||
)
|
||||
ask_inventory_on_launch = AskForField(
|
||||
blank=True,
|
||||
default=False,
|
||||
)
|
||||
ask_limit_on_launch = AskForField(
|
||||
blank=True,
|
||||
default=False,
|
||||
)
|
||||
ask_scm_branch_on_launch = AskForField(
|
||||
blank=True,
|
||||
default=False,
|
||||
)
|
||||
admin_role = ImplicitRoleField(parent_role=[
|
||||
'singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
|
||||
'organization.workflow_admin_role'
|
||||
@@ -515,7 +518,7 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
|
||||
return WorkflowJob.objects.filter(workflow_job_template=self)
|
||||
|
||||
|
||||
class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin, LaunchTimeConfigBase):
|
||||
class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin):
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
ordering = ('id',)
|
||||
|
||||
@@ -2235,7 +2235,7 @@ class RunInventoryUpdate(BaseTask):
|
||||
getattr(settings, '%s_INSTANCE_ID_VAR' % src.upper()),])
|
||||
# Add arguments for the source inventory script
|
||||
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':
|
||||
args.append("--custom")
|
||||
args.append('-v%d' % inventory_update.verbosity)
|
||||
@@ -2246,7 +2246,7 @@ class RunInventoryUpdate(BaseTask):
|
||||
def build_inventory(self, inventory_update, private_data_dir):
|
||||
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
|
||||
we pass the inventory in args to that command, so this is not considered
|
||||
to be "Ansible" inventory (by runner) even though it is
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
|
||||
# Python
|
||||
import pytest
|
||||
from unittest import mock
|
||||
import json
|
||||
|
||||
# AWX
|
||||
from awx.main.models.workflow import (
|
||||
@@ -248,7 +250,6 @@ class TestWorkflowJobTemplate:
|
||||
test_view = WorkflowJobTemplateNodeSuccessNodesList()
|
||||
nodes = wfjt.workflow_job_template_nodes.all()
|
||||
# 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.'}
|
||||
|
||||
def test_always_success_failure_creation(self, wfjt, admin, get):
|
||||
@@ -270,6 +271,103 @@ class TestWorkflowJobTemplate:
|
||||
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
|
||||
def test_workflow_ancestors(organization):
|
||||
# Spawn order of templates grandparent -> parent -> child
|
||||
|
||||
@@ -3,7 +3,7 @@ import pytest
|
||||
from awx.main.models.jobs import JobTemplate
|
||||
from awx.main.models import Inventory, CredentialType, Credential, Project
|
||||
from awx.main.models.workflow import (
|
||||
WorkflowJobTemplate, WorkflowJobTemplateNode, WorkflowJobOptions,
|
||||
WorkflowJobTemplate, WorkflowJobTemplateNode,
|
||||
WorkflowJob, WorkflowJobNode
|
||||
)
|
||||
from unittest import mock
|
||||
@@ -33,11 +33,11 @@ class TestWorkflowJobInheritNodesMixin():
|
||||
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')
|
||||
|
||||
mixin = WorkflowJobOptions()
|
||||
mixin._create_workflow_nodes(job_template_nodes)
|
||||
workflow_job = WorkflowJob()
|
||||
workflow_job._create_workflow_nodes(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():
|
||||
@pytest.fixture
|
||||
@@ -236,4 +236,4 @@ class TestWorkflowJobNodeJobKWARGS:
|
||||
|
||||
|
||||
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']
|
||||
|
||||
@@ -19,9 +19,14 @@ from functools import reduce, wraps
|
||||
from decimal import Decimal
|
||||
|
||||
# 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.functional import cached_property
|
||||
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 import Q
|
||||
|
||||
@@ -42,7 +47,7 @@ __all__ = ['get_object_or_400', 'camelcase_to_underscore', 'underscore_to_camelc
|
||||
'get_current_apps', 'set_current_apps',
|
||||
'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',
|
||||
'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',
|
||||
'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
|
||||
|
||||
|
||||
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):
|
||||
'''
|
||||
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 = {}
|
||||
for field_name in fields:
|
||||
# Foreign keys can be specified as field_name or field_name_id.
|
||||
id_field_name = '%s_id' % field_name
|
||||
if hasattr(obj1, id_field_name):
|
||||
descriptor = getattr(Class2, field_name)
|
||||
if isinstance(descriptor, ForwardManyToOneDescriptor): # ForeignKey
|
||||
# Foreign keys can be specified as field_name or field_name_id.
|
||||
id_field_name = '%s_id' % field_name
|
||||
if field_name in kwargs:
|
||||
value = kwargs[field_name]
|
||||
elif id_field_name in kwargs:
|
||||
@@ -454,15 +493,29 @@ def copy_model_by_class(obj1, Class2, fields, kwargs):
|
||||
if hasattr(value, 'id'):
|
||||
value = value.id
|
||||
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:
|
||||
if field_name == 'extra_vars' and isinstance(kwargs[field_name], dict):
|
||||
create_kwargs[field_name] = json.dumps(kwargs['extra_vars'])
|
||||
elif not isinstance(Class2._meta.get_field(field_name), (ForeignObjectRel, ManyToManyField)):
|
||||
create_kwargs[field_name] = kwargs[field_name]
|
||||
elif hasattr(obj1, field_name):
|
||||
field_obj = obj1._meta.get_field(field_name)
|
||||
if not isinstance(field_obj, ManyToManyField):
|
||||
create_kwargs[field_name] = getattr(obj1, field_name)
|
||||
create_kwargs[field_name] = getattr(obj1, field_name)
|
||||
|
||||
# Apply class-specific extra processing for origination of unified jobs
|
||||
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:
|
||||
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):
|
||||
# Many to Many can be specified as field_name
|
||||
src_field_value = getattr(obj1, field_name)
|
||||
|
||||
@@ -58,8 +58,9 @@ function canLaunchWithoutPrompt () {
|
||||
launchData.can_start_without_user_input &&
|
||||
!launchData.ask_inventory_on_launch &&
|
||||
!launchData.ask_variables_on_launch &&
|
||||
!launchData.survey_enabled &&
|
||||
!launchData.ask_limit_on_launch &&
|
||||
!launchData.ask_scm_branch_on_launch &&
|
||||
!launchData.survey_enabled &&
|
||||
launchData.variables_needed_to_start.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,34 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n) {
|
||||
},
|
||||
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: {
|
||||
label: i18n._('Labels'),
|
||||
type: 'select',
|
||||
|
||||
@@ -70,9 +70,11 @@ export default [
|
||||
data[fld] = $scope[fld];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
data.ask_inventory_on_launch = Boolean($scope.ask_inventory_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,
|
||||
$scope.variables, true);
|
||||
|
||||
@@ -54,6 +54,8 @@ export default [
|
||||
$scope.parseType = 'yaml';
|
||||
$scope.includeWorkflowMaker = false;
|
||||
$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;
|
||||
|
||||
if (Inventory){
|
||||
@@ -91,6 +93,8 @@ export default [
|
||||
}
|
||||
|
||||
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.extra_vars = ToJSON($scope.parseType,
|
||||
|
||||
@@ -69,7 +69,9 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions',
|
||||
SLICE_TEMPLATE: i18n._('Slice Job Template'),
|
||||
JOB_EXPLANATION: i18n._('Explanation'),
|
||||
SOURCE_WORKFLOW_JOB: i18n._('Source Workflow'),
|
||||
INVENTORY: i18n._('Inventory')
|
||||
INVENTORY: i18n._('Inventory'),
|
||||
LIMIT: i18n._('Inventory Limit'),
|
||||
SCM_BRANCH: i18n._('SCM Branch')
|
||||
},
|
||||
details: {
|
||||
HEADER: i18n._('DETAILS'),
|
||||
|
||||
@@ -140,6 +140,26 @@
|
||||
</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 -->
|
||||
<div class="WorkflowResults-resultRow"
|
||||
ng-show="workflow.summary_fields.workflow_job_template.name">
|
||||
|
||||
@@ -144,11 +144,15 @@ describe('Controller: WorkflowAdd', () => {
|
||||
description: "This is a test description",
|
||||
organization: undefined,
|
||||
inventory: undefined,
|
||||
limit: undefined,
|
||||
scm_branch: undefined,
|
||||
labels: undefined,
|
||||
variables: undefined,
|
||||
allow_simultaneous: undefined,
|
||||
ask_inventory_on_launch: false,
|
||||
ask_variables_on_launch: false,
|
||||
ask_limit_on_launch: false,
|
||||
ask_scm_branch_on_launch: false,
|
||||
extra_vars: undefined
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user